Something can happen because time passed, and land somewhere worth reading
Nothing in LLeMbas ever happened on its own. Every reply was downstream of
somebody pressing Send, and the one exception -- jobs.wake, waking a chat when a
background job finishes -- was downstream of a command they had run. PLAN.md
never listed scheduling as unbuilt because services/chat.py:618 had recorded it
as a decision: "a scheduler is a whole new concern for a single-worker
application". This is that concern, taken on deliberately, plus the two places
its output goes.
Reports first, because it is useful with no scheduling at all. A report is not a
Chat with one Message in it: it has no turns and no reply, it is read top to
bottom, and it must be writable with no chat behind it -- being the fallback for
a run whose own chat has gone. As a Chat it would need a sidebar row per daily
report, a title that regenerates itself, a composer to suppress and a bubble with
a rewind button around something that is not a turn. The section's character is
enforced by absence: nothing under reports/ includes the composer or renders
chat/_message.html, so there is no sse-connect anywhere and nothing on those
pages *can* start a generation. The test reads that off the OpenAPI schema, not
by walking app.routes -- this FastAPI keeps an included router wrapped rather
than flattening it, so the walk finds nothing and the assertion passes for the
wrong reason.
rule.py is pure, total, and was finished before anything called it. No session,
no wall clock, nothing that raises: validate clamps what it recognises, drops
what it does not, and answers {} for prose -- at which point the caller shows the
manual form. It had to be that way because the compile step's output is model
output that becomes a *timer*, which is the sharpest case of hard rule 6 here.
The invariant, pinned: anything validate accepts has a computable next
occurrence. A schedule that can never fire looks exactly like a working one on
every screen it appears on.
Wall-clock and elapsed time are kept apart because they mean different things.
at.times are wall-clock in the owner's zone, so 15:00 stays 15:00 across a
daylight-saving change -- that is what "every Monday at 3PM" means. every is
elapsed real time, so six hours stays six hours across a 23- or 25-hour day --
that is what a timer means. Conflating them gets one of the two wrong twice a
year. A time inside the spring-forward gap fires at the first minute that exists;
left to zoneinfo's own resolution it lands an hour away wearing a wall-clock time
that did not happen, and a daily 02:30 report vanishing once a year on a machine
nobody watches is the failure this file is arranged around.
The ticker claims and commits *before* it fires. The other order is a hot loop: a
firing that raises is retried every tick for ever against whatever it was that
failed, and the only symptom is load. Its blanket except is copied from the
terminal reaper for a sharper reason -- a ticker that dies on one bad row stops
every schedule on the instance and says nothing at all. No request fails, no
reply errors, no dot appears. The reports simply stop.
Three rules that look like bugs from outside: a firing arriving while the chat is
still answering queues rather than starting a second reply, and past max_queued
is skipped with the reason on the row; Run now does not advance next_fire_at, or
testing a schedule silently consumes the run it was testing; resuming recomputes
from now, or a schedule paused for a month fires the instant it comes back, once
per occurrence it missed. Catching up lives in the sweep and not in a startup
hook, because a suspended host and a long stall reproduce "its time passed while
nothing was running" with no restart to hang one on.
services/wake.py is the lock discipline extracted rather than copied. A finished
job and a due schedule are the same problem, and both depend on there being no
await between the running_for check and the writes; two lock dictionaries for one
invariant is how one of them drifts. jobs.wake is now a caller that supplies
wording, and _completion_text stayed exactly where it was because tool.background
quotes its opening sentence.
A scheduled run has no reader, so ask_user is withdrawn from resolve_tools rather
than merely discouraged in core.unattended -- a rule living only in a system
message is one a page the model just read can argue with, and a parked question
holds the reply for the whole approval_timeout with nobody to answer it. For the
same reason a task chat may not be an agent chat in v1: Manual, Edit and Plan all
stop to ask on RISK_EXECUTE, so the only two outcomes would be unattended
execution and a reply that stalls. That deserves its own pass.
Messages is bounded in the request and unbounded on disk. Only the latest chunk
is sent; everything else stays exactly where it was written. Nothing is folded
into text and nothing is deleted -- the visible conversation is identical either
way, so destroying the older rows would buy only disk, against being irreversible
and losing every attachment and tool call in the range, and it would contradict
the rule compaction already holds. should_compact refuses this kind for the
matching reason: two mechanisms narrowing one transcript is how a summary ends up
summarising a summary. The history route is the mirror of thread_tail and keeps
its four properties; the fifth is its own, that prepending moves the scroll
position, so app.js records scrollHeight before the swap and adds the difference
back after.
An empty Chat.kind meant "both sides of the switch" and had been read as "no
filter" since there were only two of them. The sidebar passes "" precisely when
agent chats are switched off -- so the moment a third kind existed, every task
chat and every Messages conversation appeared in somebody's ordinary chat list,
on exactly the instances whose owners would never think to look. KINDS stays the
two-sided fork, because set_sidebar_kind validates against it and a third entry
there makes the tree filterable to a side with no button to leave it; ALL_KINDS
is what a row may be. Both narrowings are pinned, because they are two
implementations of one rule and only one of them is SQL.
Per-user timezone had to exist for any of this: harness.py:179 was telling every
reader the *server's* idea of the date, which is survivable while the answer is
prose and stops being survivable the moment somebody says "every Monday at 3" and
something has to work out when that is.
Three things were caught by a test being wrong rather than by the code being
wrong. The task-chat "no composer" assertions were passing against a page
rendering its no-models-configured branch. A permission test asserted the same
thing twice because the administrator bypasses every permission. And every
Messages test passed with default_model never called, because none of them
configured a model -- so the pair it returns was being assigned straight to
model_id, and SQLite refuses a tuple in a String column. The fixtures now say why
they exist.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -124,6 +124,24 @@ class Variable:
|
||||
VARIABLES: tuple[Variable, ...] = (
|
||||
Variable("today", "Today's date", "The current date, written out in full."),
|
||||
Variable("now", "Date and time", "The current date and time, with the offset from UTC."),
|
||||
Variable(
|
||||
"schedule_instruction",
|
||||
"Scheduled instruction",
|
||||
"In a scheduled task's chat: what it is to do each time it runs. Empty "
|
||||
"everywhere else, which is what makes it the gate on the unattended "
|
||||
"guidance as well as its content.",
|
||||
),
|
||||
Variable(
|
||||
"schedule_summary",
|
||||
"Schedule",
|
||||
"In a scheduled task's chat: how often it runs, in words.",
|
||||
),
|
||||
Variable(
|
||||
"timezone",
|
||||
"Timezone",
|
||||
"The reader's timezone, as an IANA name. Empty when they have not chosen "
|
||||
"one, in which case the times above are the server's.",
|
||||
),
|
||||
Variable("instance_name", "Instance name", "What this installation is called."),
|
||||
Variable("user_name", "User's name", "The name of the person in the conversation."),
|
||||
Variable("model_name", "Model", "The display name of the model answering."),
|
||||
@@ -248,6 +266,18 @@ VARIABLES: tuple[Variable, ...] = (
|
||||
),
|
||||
Variable("question", "Question", "The first message. Chat title task only."),
|
||||
Variable("answer", "Answer", "The first reply. Chat title task only."),
|
||||
Variable(
|
||||
"request",
|
||||
"The request",
|
||||
"What somebody said they wanted to happen, in their own words. "
|
||||
"Working out a schedule only.",
|
||||
),
|
||||
Variable(
|
||||
"targets",
|
||||
"Destinations",
|
||||
"Where a scheduled run's result may be sent, as a list of the values "
|
||||
"that are accepted. Working out a schedule only.",
|
||||
),
|
||||
Variable(
|
||||
"transcript",
|
||||
"Transcript",
|
||||
@@ -573,13 +603,16 @@ BUILTIN: tuple[Fragment, ...] = (
|
||||
label="Today's date",
|
||||
group=GROUP_CORE,
|
||||
order=20,
|
||||
variables=("today",),
|
||||
variables=("today", "timezone"),
|
||||
hint="A model has no clock. Without this it cannot tell whether what it "
|
||||
"recalls is current, and will not think to check.",
|
||||
"recalls is current, and will not think to check. The timezone line "
|
||||
"carries its own variable, so it disappears on an instance where nobody "
|
||||
"has chosen one rather than announcing the server's as a decision.",
|
||||
default=(
|
||||
"Today is {{today}}. Your training data stops well before this, so treat "
|
||||
"anything time-sensitive as something to check rather than something you "
|
||||
"already know."
|
||||
"already know.\n"
|
||||
"- Times the person gives you are in {{timezone}} unless they say otherwise."
|
||||
),
|
||||
),
|
||||
Fragment(
|
||||
@@ -1117,6 +1150,72 @@ BUILTIN: tuple[Fragment, ...] = (
|
||||
),
|
||||
),
|
||||
# --- Context -------------------------------------------------------------
|
||||
Fragment(
|
||||
key="core.unattended",
|
||||
label="Nobody is watching",
|
||||
group=GROUP_CORE,
|
||||
order=35,
|
||||
requires=("schedule_instruction",),
|
||||
hint="Only in a scheduled task's chat. The point a model cannot work "
|
||||
"out for itself is that there is no reader — so the usual moves of "
|
||||
"asking what was meant, or stopping to check, end the run having done "
|
||||
"nothing. This is the prompt half; the enforcement is that `ask_user` "
|
||||
"is not offered here at all, because a rule living only in a system "
|
||||
"message is one a page the model just read can argue with.",
|
||||
default=(
|
||||
"- This chat runs on a schedule and nobody is necessarily reading it. You "
|
||||
"cannot ask a question and wait for an answer: there is no one to answer, "
|
||||
"and the run would simply end. Where something is ambiguous, choose the "
|
||||
"most reasonable reading, do the work, and say plainly in your reply what "
|
||||
"you assumed and what you would want confirmed. Finish what you were asked "
|
||||
"to do in this one reply."
|
||||
),
|
||||
),
|
||||
Fragment(
|
||||
key="context.schedule",
|
||||
label="What this task is for",
|
||||
group=GROUP_CONTEXT,
|
||||
order=310,
|
||||
variables=("schedule_instruction", "schedule_summary"),
|
||||
requires=("schedule_instruction",),
|
||||
hint="A task chat accumulates every run, so by the tenth the original "
|
||||
"instruction is far out of sight. This puts it back in front of the "
|
||||
"model each turn, the same way the current plan is — one lookup, and no "
|
||||
"guessing from the transcript.",
|
||||
default=(
|
||||
"## This scheduled task\n"
|
||||
"It runs: {{schedule_summary}}\n"
|
||||
"Each time, you are to: {{schedule_instruction}}\n"
|
||||
"Earlier runs are above. Say what has changed since the last one rather "
|
||||
"than repeating it, unless there is nothing above to compare with."
|
||||
),
|
||||
),
|
||||
Fragment(
|
||||
key="tool.report",
|
||||
label="Reports",
|
||||
group=GROUP_TOOLS,
|
||||
order=246,
|
||||
families=("report",),
|
||||
hint="Appears when the report tools are offered. The whole of what a "
|
||||
"model cannot infer from the schema is the audience: a report is read "
|
||||
"somewhere else, later, by somebody who cannot answer it. Everything "
|
||||
"else here follows from that — write it whole, do not end on a "
|
||||
"question, and do not file one for a two-line answer that has already "
|
||||
"been given in the conversation.",
|
||||
default=(
|
||||
"- You can file a report with report_write: a finished piece of work, kept "
|
||||
"where the person will find it later. Write one when you are asked for one, "
|
||||
"and when you finish something long enough that its result is worth keeping — "
|
||||
"an investigation, an account of what you changed, a summary of what you "
|
||||
"found. Do not file one for an answer you have just given in two lines; the "
|
||||
"conversation already holds that. A report is read on its own, away from this "
|
||||
"chat and possibly long afterwards, and the person cannot reply to it — so "
|
||||
"say what you were asked, what you found and what you conclude, refer to "
|
||||
"nothing above, and end on a finding rather than a question. report_search "
|
||||
"and report_get read back ones filed earlier, which is worth doing before a "
|
||||
"recurring report so this one can say what changed."
|
||||
),
|
||||
),
|
||||
Fragment(
|
||||
key="context.knowledge_scope",
|
||||
label="Which knowledge bases",
|
||||
@@ -1518,6 +1617,57 @@ BUILTIN: tuple[Fragment, ...] = (
|
||||
"from there."
|
||||
),
|
||||
),
|
||||
Fragment(
|
||||
key="task.schedule_compile",
|
||||
label="Working out a schedule",
|
||||
group=GROUP_TASKS,
|
||||
order=440,
|
||||
variables=("request", "now", "timezone", "targets"),
|
||||
hint="One request, made once, when somebody describes something they "
|
||||
"want to happen later. It turns their words into a recurrence and into "
|
||||
"an instruction that reads sensibly with no conversation around it — "
|
||||
"which is how it will be read, days later, by a model that was not "
|
||||
"there when it was typed. Clearing this switches off the *working out*, "
|
||||
"not scheduling: the setup screen then asks for the time in its own "
|
||||
"fields, with the reader's words already filled in. The reply is parsed "
|
||||
"leniently and anything unusable falls back to that same form, so a "
|
||||
"model that answers in prose costs a moment rather than a broken "
|
||||
"schedule.",
|
||||
default=(
|
||||
"Turn the request below into a schedule. Reply with one JSON object and "
|
||||
"nothing else — no commentary, no code fence.\n"
|
||||
"\n"
|
||||
"It is currently {{now}} ({{timezone}}). Times you write are in that zone.\n"
|
||||
"\n"
|
||||
"The object has these keys:\n"
|
||||
'- "title": a short name for this, five words or fewer.\n'
|
||||
'- "instruction": what should be done each time it runs, written out in '
|
||||
"full. It will be read on its own, with none of this conversation around "
|
||||
"it and nobody available to answer a question about it, so say everything "
|
||||
"it needs. Write it as an instruction, not as a description.\n"
|
||||
'- "target": where the result goes — one of: {{targets}}. Use "report" '
|
||||
"when the point is something to read later, and \"chat\" otherwise.\n"
|
||||
'- "schedule": an object saying when, with these optional keys:\n'
|
||||
' "start": an ISO timestamp for the first (or only) run.\n'
|
||||
' "every": one of {"minutes": n}, {"hours": n}, {"days": n}, '
|
||||
'{"weeks": n} — a plain timer.\n'
|
||||
' "at": {"weekdays": [0-6, Monday is 0], "days": [1-31], '
|
||||
'"months": [1-12], "times": ["HH:MM"]} — a calendar. Leave a list out '
|
||||
"to mean every one of them.\n"
|
||||
' "count": how many times in total, if they said a number.\n'
|
||||
' "until": an ISO timestamp to stop after, if they gave one.\n'
|
||||
"\n"
|
||||
'Use "every" for "in ten minutes" or "every six hours". Use "at" for '
|
||||
'"every Monday at 3" or "daily at nine". Use both only for something '
|
||||
'like "every other Tuesday". For a one-off, give "start" alone.\n'
|
||||
"\n"
|
||||
"If they did not say when, guess the most ordinary reading rather than "
|
||||
"leaving it out — daily at 09:00 for something described as daily.\n"
|
||||
"\n"
|
||||
"The request:\n"
|
||||
"{{request}}"
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
register_source(_builtin_source)
|
||||
|
||||
Reference in New Issue
Block a user