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:
co-authored by
Claude Opus 5
parent
178742501d
commit
9ddc0a2103
@@ -0,0 +1,128 @@
|
||||
{% from "_macros.html" import icon %}
|
||||
{#
|
||||
The manual rule form, shared by new and edit.
|
||||
|
||||
Every field maps to one key `services/schedule/rule.py:validate` understands,
|
||||
and nothing here validates: there is one normaliser, it is total, and it is
|
||||
the same one a compiled rule goes through. Two validators would be two ideas
|
||||
of what a legal schedule is.
|
||||
|
||||
The repeat fieldsets are shown and hidden by the radio's `:has()` selector in
|
||||
admin.css — no JavaScript, and nothing that can fall out of step with the
|
||||
control, which is the same reasoning the "Something else" box on an approval
|
||||
card follows.
|
||||
#}
|
||||
<div class="field">
|
||||
<label class="field__label" for="schedule-title">Name</label>
|
||||
<input class="input" id="schedule-title" name="title" required maxlength="200"
|
||||
value="{{ form.title }}"
|
||||
placeholder="Monday build check">
|
||||
<p class="field__hint">What this appears as in the list.</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="schedule-instruction">What should happen</label>
|
||||
<textarea class="input" id="schedule-instruction" name="instruction" rows="5" required
|
||||
placeholder="Check whether the build is passing and summarise anything that broke since last week."
|
||||
>{{ form.instruction }}</textarea>
|
||||
<p class="field__hint">
|
||||
Written for a model that will read it with no conversation around it, so say
|
||||
the whole thing. Nobody will be there to answer a question about it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="schedule-target">Where the result goes</label>
|
||||
<select class="select" id="schedule-target" name="target">
|
||||
{% for value, label in targets %}
|
||||
<option value="{{ value }}" {{ 'selected' if form.target == value }}>
|
||||
{{ label }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<fieldset class="schedule-repeat">
|
||||
<legend class="field__label">When</legend>
|
||||
|
||||
<label class="checkbox">
|
||||
<input type="radio" name="repeat" value="once"
|
||||
{{ 'checked' if form.repeat == 'once' }}>
|
||||
<span>Once</span>
|
||||
</label>
|
||||
<label class="checkbox">
|
||||
<input type="radio" name="repeat" value="every"
|
||||
{{ 'checked' if form.repeat == 'every' }}>
|
||||
<span>Every so often</span>
|
||||
</label>
|
||||
<label class="checkbox">
|
||||
<input type="radio" name="repeat" value="calendar"
|
||||
{{ 'checked' if form.repeat == 'calendar' }}>
|
||||
<span>On particular days</span>
|
||||
</label>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="schedule-start">Starting</label>
|
||||
<div class="btn-row">
|
||||
<input class="input" type="date" id="schedule-start" name="start_date">
|
||||
<input class="input" type="time" name="start_time" value="09:00">
|
||||
</div>
|
||||
<p class="field__hint">
|
||||
Leave the date empty for a repeating schedule and it begins now.
|
||||
Times are {{ timezone }}.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field" data-repeat="every">
|
||||
<label class="field__label" for="schedule-every">Repeat every</label>
|
||||
<div class="btn-row">
|
||||
<input class="input" type="number" id="schedule-every" name="every_amount"
|
||||
min="1" value="{{ form.every_amount }}" style="max-width: 8rem">
|
||||
<select class="select" name="every_unit">
|
||||
{% for unit in ["minutes", "hours", "days", "weeks"] %}
|
||||
<option value="{{ unit }}" {{ 'selected' if form.every_unit == unit }}>{{ unit }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field" data-repeat="calendar">
|
||||
<label class="field__label">On these days</label>
|
||||
<div class="checkbox-row">
|
||||
{% for index, name in weekday_names %}
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="weekdays" value="{{ index }}"
|
||||
{{ 'checked' if index in form.weekdays }}>
|
||||
<span>{{ name }}</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<p class="field__hint">Leave all of them unticked for every day.</p>
|
||||
|
||||
<label class="field__label" for="schedule-times">At</label>
|
||||
<input class="input" id="schedule-times" name="times"
|
||||
value="{{ form.times or '09:00' }}" placeholder="09:00, 17:00">
|
||||
<p class="field__hint">
|
||||
One or more times, separated by commas. These are wall-clock times: 09:00
|
||||
stays 09:00 when the clocks change.
|
||||
</p>
|
||||
|
||||
<label class="field__label" for="schedule-monthdays">Only on these dates</label>
|
||||
<input class="input" id="schedule-monthdays" name="month_days"
|
||||
value="{{ form.month_days }}" placeholder="1, 15">
|
||||
<p class="field__hint">Days of the month, if you want it narrower. Optional.</p>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="schedule-count">Stop after</label>
|
||||
<input class="input" type="number" id="schedule-count" name="count" min="0"
|
||||
value="{{ form.count }}" style="max-width: 10rem">
|
||||
<p class="field__hint">Number of runs. Leave it at 0 to keep going until you stop it.</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="schedule-until">Or stop on</label>
|
||||
<input class="input" type="date" id="schedule-until" name="until">
|
||||
<p class="field__hint">Optional. Nothing runs after this date.</p>
|
||||
</div>
|
||||
@@ -0,0 +1,39 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_macros.html" import icon %}
|
||||
{#
|
||||
The Scheduled shell: the list and the setup forms.
|
||||
|
||||
A schedule's own *chat* is not rendered here — it is the ordinary chat page
|
||||
with its composer replaced, which is the whole reason a task chat is a `Chat`.
|
||||
#}
|
||||
|
||||
{% block head %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='css/chat.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='css/admin.css') }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block body_attrs %} data-authenticated="true"{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="shell">
|
||||
{% include "partials/sidebar.html" %}
|
||||
|
||||
<main class="main">
|
||||
<header class="topbar">
|
||||
<h1 class="topbar__title">{% block heading %}Scheduled{% endblock %}</h1>
|
||||
<div class="topbar__actions">{% block actions %}{% endblock %}</div>
|
||||
</header>
|
||||
|
||||
<div class="admin-scroll">
|
||||
<div class="admin-page">
|
||||
{% if error %}
|
||||
<div class="alert alert--warning">
|
||||
{{ icon("warning", "alert__icon") }} <span>{{ error }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% block schedules_content %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,68 @@
|
||||
{% from "_macros.html" import icon %}
|
||||
{#
|
||||
What sits where a task chat's composer would be.
|
||||
|
||||
Three plain forms rather than htmx: each one changes what the page says about
|
||||
itself — when it next runs, whether it is paused, a whole new reply — so a
|
||||
redirect back is the honest response, and a refresh cannot re-submit it.
|
||||
|
||||
Every button here is the reader acting on their own schedule, so nothing is
|
||||
checked against the chat's mode. That is the same argument the terminal panel,
|
||||
the directory browser and the jobs panel already make: the model is governed,
|
||||
the person is not.
|
||||
#}
|
||||
<div class="composer">
|
||||
<div class="composer__inner">
|
||||
{% if schedule %}
|
||||
<div class="jobs__row jobs__row--open" style="justify-content: space-between">
|
||||
<div style="min-width: 0">
|
||||
<strong>{{ schedule_summary }}</strong>
|
||||
<div class="text-xs faint">
|
||||
{%- if not schedule.enabled %}
|
||||
Paused.
|
||||
{%- elif schedule_next %}
|
||||
Next run {{ schedule_next.strftime("%a %-d %b, %H:%M") }}.
|
||||
{%- else %}
|
||||
Nothing left to run.
|
||||
{%- endif %}
|
||||
{%- if schedule.fired_count %} Run {{ schedule.fired_count }} times so far.{% endif %}
|
||||
{%- if schedule.target == "report" %} Files a report each time.{% endif %}
|
||||
</div>
|
||||
{% if schedule.last_error %}
|
||||
<div class="text-xs faint">{{ schedule.last_error }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="btn-row">
|
||||
<form method="post" action="/api/schedules/{{ schedule.id }}/run">
|
||||
<button class="btn btn--sm" type="submit" title="Run it now without using up the next scheduled run">
|
||||
{{ icon("bolt", "icon--sm") }} Run now
|
||||
</button>
|
||||
</form>
|
||||
<form method="post" action="/api/schedules/{{ schedule.id }}/toggle">
|
||||
<input type="hidden" name="enabled" value="{{ '0' if schedule.enabled else '1' }}">
|
||||
<button class="btn btn--sm" type="submit">
|
||||
{% if schedule.enabled %}
|
||||
{{ icon("stop-circle", "icon--sm") }} Pause
|
||||
{% else %}
|
||||
{{ icon("refresh", "icon--sm") }} Resume
|
||||
{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
<a class="btn btn--sm" href="/scheduled/{{ schedule.id }}/edit">
|
||||
{{ icon("gear", "icon--sm") }} Edit
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
{#
|
||||
The schedule was removed but its chat was kept, which is the default when
|
||||
somebody deletes one. Said plainly rather than left as an empty bar.
|
||||
#}
|
||||
<p class="nav-empty">
|
||||
This chat belonged to a schedule that has been removed. It is kept as a
|
||||
record of what was done.
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,37 @@
|
||||
{% extends "schedules/_layout.html" %}
|
||||
{% from "_macros.html" import icon %}
|
||||
{% set section = "scheduled" %}
|
||||
|
||||
{% block title %}{{ schedule.title }} - LLeMbas{% endblock %}
|
||||
{% block heading %}{{ schedule.title }}{% endblock %}
|
||||
{% block actions %}
|
||||
<a class="btn btn--sm" href="/chat/{{ schedule.chat_id }}">Open its chat</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block schedules_content %}
|
||||
<form method="post" action="/api/schedules/{{ schedule.id }}" class="card">
|
||||
{% include "schedules/_form.html" %}
|
||||
<div class="btn-row">
|
||||
<button class="btn btn--primary" type="submit">Save</button>
|
||||
<a class="btn" href="/scheduled">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="card">
|
||||
<h2 class="card__title">Remove</h2>
|
||||
<p class="card__lede">
|
||||
Stops it running. Its chat is kept by default and becomes an ordinary one,
|
||||
so the transcript of everything it has already done stays where it is.
|
||||
</p>
|
||||
<form method="post" action="/api/schedules/{{ schedule.id }}/delete"
|
||||
data-confirm="Remove this schedule? It will stop running.">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="keep_chat" value="1" checked>
|
||||
<span>Keep its chat</span>
|
||||
</label>
|
||||
<button class="btn btn--danger btn--sm" type="submit">
|
||||
{{ icon("trash", "icon--sm") }} Remove schedule
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,54 @@
|
||||
{% extends "schedules/_layout.html" %}
|
||||
{% from "_macros.html" import icon %}
|
||||
{% set section = "scheduled" %}
|
||||
|
||||
{% block title %}Scheduled - LLeMbas{% endblock %}
|
||||
{% block heading %}Scheduled{% endblock %}
|
||||
{% block actions %}
|
||||
<a class="btn btn--primary btn--sm" href="/scheduled/new">
|
||||
{{ icon("plus", "icon--sm") }} New scheduled task
|
||||
</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block schedules_content %}
|
||||
<p class="admin-lede">
|
||||
Work that runs on its own, whether or not you are here. Each one has its own
|
||||
chat, and replies into it every time it comes round.
|
||||
</p>
|
||||
|
||||
{% if not schedules %}
|
||||
<div class="empty">
|
||||
{{ icon("clock", "empty__mark") }}
|
||||
<h2 class="empty__title">Nothing scheduled</h2>
|
||||
<p class="empty__text">
|
||||
Set something to run later — a daily summary, a check every Monday
|
||||
morning, a reminder in an hour.
|
||||
</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<ul class="model-list">
|
||||
{% for item in schedules %}
|
||||
<li class="model-list__item">
|
||||
<div style="min-width: 0">
|
||||
<a href="/chat/{{ item.row.chat_id }}"><strong>{{ item.row.title }}</strong></a>
|
||||
<div class="text-xs faint">
|
||||
{{ item.summary }}
|
||||
{%- if item.next and item.row.enabled %}
|
||||
· next {{ item.next.strftime("%a %-d %b, %H:%M") }}
|
||||
{%- endif %}
|
||||
{%- if item.row.fired_count %} · run {{ item.row.fired_count }} times{% endif %}
|
||||
</div>
|
||||
{% if item.row.last_error %}
|
||||
<div class="text-xs faint">{{ item.row.last_error }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
{% if item.row.target == "report" %}<span class="badge">files a report</span>{% endif %}
|
||||
{% if not item.row.enabled %}<span class="badge badge--warning">paused</span>{% endif %}
|
||||
<a class="btn btn--sm" href="/scheduled/{{ item.row.id }}/edit">Edit</a>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,78 @@
|
||||
{% extends "schedules/_layout.html" %}
|
||||
{% from "_macros.html" import icon %}
|
||||
{% set section = "scheduled" %}
|
||||
|
||||
{% block title %}New scheduled task - LLeMbas{% endblock %}
|
||||
{% block heading %}What do you want to schedule?{% endblock %}
|
||||
{% block actions %}
|
||||
<a class="btn btn--sm" href="/scheduled">Cancel</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block schedules_content %}
|
||||
{#
|
||||
One question first, and the detail worked out from the answer. The manual
|
||||
fields are on the same page rather than behind a second screen: somebody who
|
||||
already knows exactly when it should run should not have to describe it in
|
||||
prose and hope, and the fields are also where a compile that did not work out
|
||||
lands — so they cannot be a fallback that only appears when something breaks.
|
||||
#}
|
||||
<form method="post" action="/api/schedules/describe" class="card">
|
||||
<div class="field">
|
||||
<label class="field__label" for="describe">Say it in your own words</label>
|
||||
<textarea class="input" id="describe" name="request" rows="3" required
|
||||
placeholder="Every Monday morning, check whether the build is passing and write me a report."
|
||||
>{{ described }}</textarea>
|
||||
<p class="field__hint">
|
||||
Times are read as {{ timezone }}. Say how often, and what should happen.
|
||||
</p>
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
<button class="btn btn--primary" type="submit">
|
||||
{{ icon("sparkle", "icon--sm") }} Work it out
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% if compiled %}
|
||||
{% if compiled.reason %}
|
||||
<div class="alert alert--warning">
|
||||
{{ icon("warning", "alert__icon") }} <span>{{ compiled.reason }}</span>
|
||||
</div>
|
||||
{% elif summary %}
|
||||
<div class="alert alert--success">
|
||||
{{ icon("check", "alert__icon") }}
|
||||
<span>
|
||||
This will run <strong>{{ summary }}</strong>. Check it below and change
|
||||
anything that is not what you meant.
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/api/schedules" class="card">
|
||||
{#
|
||||
Pre-filled from the compile when there was one. `schedule` is None here, so
|
||||
the shared fields fall back to their empty state — which is why the two
|
||||
compiled text values are written in explicitly rather than left to it.
|
||||
#}
|
||||
{% include "schedules/_form.html" %}
|
||||
|
||||
{% if models %}
|
||||
<div class="field">
|
||||
<label class="field__label" for="schedule-model">Model</label>
|
||||
<select class="select" id="schedule-model" name="model_id">
|
||||
<option value="">Whatever is default at the time</option>
|
||||
{% for model in models %}
|
||||
<option value="{{ model.model_id }}">{{ model.display_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="btn-row">
|
||||
<button class="btn btn--primary" type="submit">
|
||||
{{ icon("clock", "icon--sm") }} Schedule it
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user