Commit graph

8 commits

Author SHA1 Message Date
Edenman
c33ca7227a
fix(web-shell): restore scheduled task reference interactions (#7313)
* fix(web-shell): restore scheduled task reference interactions

* chore(web-shell): remove PR screenshot artifact

* fix(web-shell): refine scheduled task tag removal

---------

Co-authored-by: 克竟 <dingbingzhi.dbz@alibaba-inc.com>
2026-07-21 07:10:26 +00:00
Shaojin Wen
30aafe0914
feat(web-shell): aggregate scheduled tasks across all workspaces (#6759)
* feat(web-shell): aggregate scheduled tasks across all workspaces

The Web Shell scheduled-tasks page was hard-wired to the primary workspace. On a multi-workspace daemon, creating a task while viewing another workspace stored it under (and ran it in) the primary, and tasks belonging to secondary workspaces never appeared on the page.

Add a workspace-qualified `/workspaces/:workspace/scheduled-tasks` surface that shares the existing CRUD handlers, resolving each request to that workspace's own cron file and session bridge after a trust check — the same pattern every other qualified route uses. The page now aggregates every trusted workspace's tasks into one list, badges each card with its workspace, and lets the New-task form target a workspace. Keepalive and boot rehydration run per registered workspace so a bound task in any workspace fires and survives a restart, instead of only the primary's.

* fix(web-shell): keep an untrusted primary in the scheduled-tasks aggregate

The aggregated list and the New-task workspace picker were both built from the trusted workspaces only. When folder trust is enabled and the primary workspace itself is untrusted, that dropped the primary from the picker and skipped it during the fan-out — so its tasks disappeared from the list even though the primary's trust-free route can still read them, and the form's default (which targets the primary) no longer matched any option, letting the UI show a secondary while a create actually landed on the primary.

Include the primary in the operable set regardless of its trust flag; it is always reachable through the unqualified route the single-workspace page has always used. Secondary workspaces stay gated on trust, since their qualified route rejects an untrusted read or write.
2026-07-12 12:03:30 +00:00
Edenman
1bfd213e9d
feat(web-shell): add scheduled task prompt references (#6589)
* feat(web-shell): add scheduled task prompt references

* fix(web-shell): render saved scheduled task references

* fix(web-shell): address scheduled task reference review

* fix(web-shell): preserve prompt reference clipboard tokens

* fix(web-shell): address scheduled task review feedback

---------

Co-authored-by: 克竟 <dingbingzhi.dbz@alibaba-inc.com>
Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
2026-07-11 10:20:43 +00:00
Shaojin Wen
65393d0377
feat(daemon): record & query sub-session parentSessionId; drop isolated scheduled-task mode (#6676)
Some checks are pending
E2E Tests / web-shell Browser Regression (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none (push) Waiting to run
E2E Tests / E2E Test - macOS (push) Waiting to run
* feat(daemon): record sub-session parentSessionId; drop isolated scheduled-task mode

Two changes bundled from this session (recovered after an external
merge-test harness force-reset the worktree):

1. Remove the scheduled-task "isolated" run mode and its precondition
   machinery. All tasks now run in their bound session (shared). The
   create_sub_session tool is retained so users can request isolation
   from a prompt. Drops runMode/condition/withheld across the persistence
   type, scheduler, Session dispatch, REST route, Web Shell UI, i18n, and
   the daemon/SDK contract, with tests updated.

2. Add an immutable parentSessionId to sub-sessions created via
   create_sub_session, wired end to end: BridgeSpawnRequest -> SessionEntry
   -> session summary, persisted into the child transcript (new
   parent_session record + qwen/control/session/parent ext-method) and
   rehydrated by listSessions on daemon restart, surfaced on
   BridgeSessionSummary / DaemonSessionSummary.

* test(daemon): cover sub-session parentSessionId end to end

- chatRecordingService: recordParentSession writes a parent_session record
- sessionService: listSessions reads parentSessionId back from the transcript,
  including the head-window fallback past the 64KB tail
- bridge: spawnOrAttach threads parentSessionId to the session summary and
  dispatches the sessionParent ext-method (and not when there is no parent)
- create-sub-session launcher: passes callerSessionId as parentSessionId

* feat(daemon): add parentSessionId filter to the session list API

GET /workspace/:id/sessions accepts a `parentSessionId` query param that
returns only the sessions spawned by that parent (via create_sub_session).
The default path gathers the whole workspace (persisted + live) and filters
before paginating, so a page is never short of matches; results are sorted
newest-activity-first with an opaque activity cursor. Rejected (400) when
combined with view=organized. Threaded through the SDK
(DaemonSessionListPageOptions.parentSessionId / listWorkspaceSessions).

* test(daemon): cover the parentSessionId session-list filter

Response-builder cases: filters to a parent's children, empty when none,
paginates the filtered set completely (no dupes/gaps). Route cases: the
query param filters GET /workspace/:id/sessions, 400s with view=organized
(invalid_parent_session_filter) and on an empty value
(invalid_parent_session_id).

* fix(daemon): address review — fail closed on removed scheduled-task fields; scope & persist parentSessionId

Review follow-ups on the isolated-removal + parentSessionId work:

- scheduled-tasks REST: reject a POST/PATCH that still carries `runMode` or
  `condition` (400 unsupported_field) instead of silently ignoring them, so a
  stale SDK / cached Web Shell fails closed rather than getting a plain
  unconditional task in place of the guarded one it asked for.
- cron scheduler: a legacy on-disk task that still carries a `condition`
  precondition is skipped (left on disk), not run inline unconditionally —
  a removed safety gate must not silently become "always run". One-time
  operator breadcrumb points at remediation.
- bridge: await + verify the sessionParent persistence instead of
  fire-and-forget, so a dropped transcript write is surfaced loudly rather
  than silently degrading the parent link to live-only after a restart. The
  child is not rolled back on failure.
- session list: the ?parentSessionId= page cursor now binds its
  parentSessionId + archiveState and rejects a cursor replayed against a
  different parent / archive scope, matching the organized cursor contract.

Tests added for all four.

* fix(daemon): address review round 2 — legacy tasks fail closed on all paths; surface parentSessionId persistence outcome

- cron: remove obsolete callback-level "guarded task" tests (headless +
  interactive) — the guard moved to the scheduler load (fail-closed), so
  those callbacks never receive a guarded job. Fixes the CI failures.
- scheduled-tasks REST: a legacy on-disk task that still carries a
  `condition` is now failed closed on EVERY path, not just the scheduler
  tick — GET reports it `enabled:false` with no `nextRunAt`, and POST /run
  rejects it (409 task_legacy_unsupported). Shared `taskHasLegacyCondition`
  helper (exported from core) is the single source of truth.
- bridge: the sub-session parent-lineage write is now timeout-bounded
  (withTimeout, like the other init round-trips) and retried, and its
  outcome is surfaced to the caller via BridgeSession.parentSessionPersisted
  (threaded through the launcher → ext-method → spawner → tool) instead of
  only stderr. create_sub_session warns the model when the link is
  live-only. Requires persisted===true for success.

Tests added for all of the above.

* fix(daemon): reject enabling a legacy guarded task via PATCH

Follow-up: toView reports a legacy `condition` task as disabled, so the
only PATCH the Web Shell sends for it is the Enable toggle. Accepting that
(200) then reading back disabled again is an Enable control that can never
succeed with no error. PATCH `{enabled:true}` on such a task now returns
409 task_legacy_unsupported with the recreate remediation. Test added.

* fix(daemon): address review round 3 — warn on legacy isolated tasks; idempotent parent record; perf + dedup

Addressing ytahdn's review:
- cron: a bare `runMode: 'isolated'` task (no precondition) has no safety
  gate, so it still fires — but it now accumulates history in its bound
  session instead of a fresh per-run one. It runs, with a one-time
  operator warning (the condition subset still fails closed). New shared
  `taskHasLegacyRunMode` helper.
- chatRecordingService: `recordParentSession` is now idempotent (skips a
  repeat append for the same immutable lineage) — matters because the
  bridge write is now retried.
- sessionService: read `parentSessionId` from the records already loaded
  for the listing instead of a second per-file open (the record is written
  at creation, within the scan window) — removes the extra I/O on the hot
  listing path.
- session-list: extract the persisted+live merge into one
  `mergeLiveSessionSummary` helper (was duplicated across the default,
  organized and by-parent paths).

Tests: legacy-runMode fires+warns-once, recordParentSession idempotency,
and the acpAgent sessionParent ext-method handler (valid / no-recording /
invalid-params).

* fix(daemon): address deep review — lineage on fork/restore/SDK; durable parent write; legacy tasks fail closed everywhere

GPT-5 review round + a two-pass reverse audit:

- forkSession no longer copies the source's `parent_session` record, so a
  fork is a fresh top-level session, not a phantom child of the original.
- restore/resume now re-seed the persisted `parentSessionId` on the live
  entry (both the REST handler and the ACP-HTTP transport — the audit caught
  the ACP path being missed), so a restored sub-session still reports its
  parent after a daemon restart.
- SDK: the ACP route mapping + `session/list` dispatcher now forward,
  filter, and project `parentSessionId`; `WorkspaceDaemonClient` serializes
  it too — the filter works over every SDK transport, not just the root HTTP
  client.
- recordParentSession is awaited via the strict append path, so
  `persisted: true` never claims a write that silently failed; it stays
  idempotent so a retry can't double-append.
- bridge parent-write is raced against transport close and treats a timeout
  as terminal (not a retry that would overlap an in-flight request), under
  one deadline.
- legacy guarded tasks (removed isolated mode + precondition) now fail closed
  on ALL consumers — cron_list reports them disabled, keepalive won't bind or
  keep their sessions resident — not just the scheduler tick.
- a bare legacy `runMode: 'isolated'` task (no gate) still runs, with a
  one-time behavior-change warning.
- restored `withheld` as a read-only compat field so pre-upgrade withheld
  run-history is marked "skipped", not shown as a successful run.

Tests added/updated across all of the above.

---------

Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
2026-07-10 17:15:13 +00:00
Shaojin Wen
dd62c3a8e5
feat(scheduled-tasks): gate an isolated run behind a precondition (#6619)
* feat(scheduled-tasks): gate an isolated run behind a precondition

An isolated scheduled task now takes an optional `condition` alongside its
prompt. On every fire the task's bound session evaluates the condition as an
ordinary cron turn, and only dispatches the prompt into a fresh sub-session
when that turn's verdict is YES.

The check deliberately runs in the bound session rather than a throwaway
sub-session:

  - It has exactly the semantics of a `shared` fire — same tools, same
    workspace approval mode — so it introduces no new permission surface.
  - The bound session of an isolated task is otherwise empty, so its
    transcript becomes the task's decision log: the record of why a fire did
    or did not happen.
  - No session is minted for a run that never occurs.

Everything that is not a YES skips the fire: NO, an unparseable answer, a
tool-loop error, a cancelled or timed-out turn. A precondition exists to
withhold an unattended run, so an ambiguous answer must withhold it too.

A `missed` (late-delivered) fire is judged the same way and then keeps its
existing in-session path — a precondition changes whether a fire runs, never
how. The Web Shell's "Run now" evaluates the condition before relaying the
dispatch through the model, so a manual run reproduces a scheduled one and
doubles as the way to test that a condition is written correctly.

The field is isolated-only. `POST`/`PATCH /scheduled-tasks` reject a condition
on a shared task, judging the combined post-patch state so a condition can be
stranded from neither side; the check is gated on the request actually
touching `condition` or `runMode`, so a hand-edited stranded task stays
editable. `isValidTask` requires a non-empty string, since the fire path gates
on truthiness and an empty condition would silently un-guard the task.

* fix(scheduled-tasks): harden the precondition against four review findings

Never judge a `missed` fire. That job is the scheduler's synthetic carrier:
one batched notification covering every one-shot missed in this load, built
from a spread of the first task, whose prompt is a notice ("these were missed
— ask the user before running them") rather than any task's command. Gating it
on the first task's precondition let a `NO` silently suppress the notice for
its siblings, which `removeMissedFromDisk` has already deleted. The scheduler
now strips per-task guard state from the carrier, and the session refuses to
judge a missed fire — the same contract enforced from both ends.

Distinguish a truncated turn from a clean one. A permission cancel or a
detected tool loop returns mid-tool-loop without aborting the turn's signal or
recording an error, so it reached `onComplete` as `'ok'`. A model that emits
`DECISION: YES` as text in the same streaming round as its tool call would then
release the fire on a verdict it never got to revise. Add an `'incomplete'`
outcome, set at both early returns.

Require the verdict to be the whole line. `\b` after the verdict accepted
`DECISION: YES, but I could not verify it` as a YES. The prompt asks for a line
that is exactly one of the two; a hedged answer is not a decision, and a
precondition must fail closed on an answer it cannot trust. Closing markdown
and terminal punctuation are still tolerated.

Require a bound session for a condition. The check is evaluated in the task's
own session — that is what makes its transcript a decision log. A task with no
`sessionId` (tool-created, or created with no bridge to bind one) fires through
the shared per-project durable owner, so its check would be injected into
whichever session holds that lock. Both create and update now reject a
condition on such a task instead of quietly relocating the check.

Also log the decision point: a non-ok outcome reaches stderr, since the
scheduler has already booked the run and `debugLogger` writes nothing unless a
debug log session is active.

* fix(scheduled-tasks): close four more precondition gaps from review

Read the verdict off the final non-empty line, not from anywhere in the text.
The prompt asks the model to *end* its reply with the verdict, so
`DECISION: YES\n\nBut I could not verify it` is not a decision — scanning the
whole reply took the conclusion off the wrong line and released the fire.

Mark a cut-short tool loop at its choke point. `loopDetected` was flagged, but
its sibling `repeatedDuplicateProviderToolCall` takes the quiet exit: it makes
`#buildNextMessageAfterToolRun` return null, ending the turn with no error and
no abort. A model that streamed `DECISION: YES` in that same round then
released the fire on an investigation it never finished. Both cases (and any
future one) are now marked where the follow-up message comes back null, rather
than by enumerating flags — enumerating them is how the sibling was missed.

Fail closed in the two consumers that cannot evaluate a precondition. The
headless and TUI `onFire` callbacks read only `prompt`/`cronExpr`/`missed`, so
a guarded task fired there with its guard ignored — the exact outcome the
precondition exists to prevent. Both now skip such a fire; only the ACP/daemon
session, which owns the sub-session dispatch the verdict gates, runs it.

Distinguish a withheld fire in the run history. The scheduler books the run the
moment it fires, before any verdict exists, so a task that deliberately did
nothing reported "ran at 02:00". `CronTaskRun.withheld` is stamped afterwards
by the evaluating session, addressed by the fire's own minute (the scheduler
writes `runs[].at` from the very `lastFiredAt` it hands to `onFire`), and the
Web Shell tags the entry. Best-effort and never awaited: losing a cosmetic
marker must not affect a fire that has already been decided.

* feat(scheduled-tasks): make the precondition readable, and translate it

The bound session of a guarded task is the feature's decision log, but it read
like a debug dump: every fire echoed the whole instruction wrapper the model
receives — five paragraphs of "end your reply with a final line that is exactly
one of…" — and nothing in it was translatable.

Echo a compact label instead. `CronQueueItem` gains an optional `echoText`: the
text the client shows when the text sent to the model is not fit to read. A
precondition turn now shows " Precondition check" and the user's own condition,
whitespace-collapsed and capped at 280 characters (surrogate-safe). The model
still receives the full wrapper.

Say what the check decided. The model's answer explains its reasoning but cannot
state the consequence, so the scheduler adds one line: the run was skipped
(precondition not met, or the check was cancelled / interrupted / failed), or it
is running — with a `qwen-session://` link to the sub-session that is doing the
work. Without that link the bound session of an isolated task shows nothing at
all for a fire that DID run: the work happens in a sibling the user cannot
reach from here.

The status line opens with a blank line. It is an `agent_message_chunk`, which
the client appends to the assistant message already on screen, and that message
ends on the verdict with no trailing newline — without the break the transcript
renders `DECISION: NO Precondition not met…`. A screenshot caught that; the
assertions did not, so there is now a test for it.

All seven strings go through `t()` and are translated in en/zh/zh-TW (the three
locales `check-i18n` holds to strict key parity). Session.ts had no i18n import
before this; `t()` is initialized on the ACP path by `gemini.tsx`.

Not addressed: the ACP cron path persists no user record at all, so the echo and
the status lines are live-only and a reload shows the model's answers with no
question above them. That is pre-existing — `client.ts` records a cron prompt via
`recordCronPrompt(message, displayText)` only on the core send path, which the
ACP session does not use.

* fix(web-shell): make qwen-session:// links actually clickable

`MarkdownLink` has an interception branch for `qwen-session://<id>` that renders
a button and dispatches `qwen:open-session` so the app shell can navigate. It
has never run.

react-markdown sanitizes every href through `defaultUrlTransform`, which allows
only `http(s)`, `irc(s)`, `mailto` and `xmpp` and rewrites everything else to
`''`. So the scheme was stripped before `components.a` was called: the branch
saw an empty href, fell through, and rendered a plain anchor with no href.
Clicking it did nothing.

Add a `urlTransform` that passes `qwen-session://` through and defers every
other url to the default sanitizer. Letting the scheme through is safe — the
interception branch never puts it in the DOM, it renders `href="#"` and
dispatches the id as an event — and the per-component `isSafeHref` /
`isSafeImageSrc` guards are unchanged.

Dead since #6535 (ac2f371c4) introduced it: the `create_sub_session` tool's link
works only because `ToolGroup` parses `[text](qwen-session://id)` out of plain
tool output with its own regex, never touching react-markdown. A precondition's
"running this task in a new session" line is the first such link to reach an
assistant message, which is how this surfaced.

* fix(scheduled-tasks): stop pretending a manual run evaluates the precondition

"Run now" wrapped the task's condition into the relayed prompt and let the model
decide whether to dispatch. That cannot be made correct from the client, and it
produced three defects:

  - `onRunPrompt` resolves at ADMISSION, so `runScheduledTask` appended an
    ordinary `manual` run record even when the model went on to decide the
    condition was false. The history reported a successful run for work that was
    never done, and — unlike a scheduled fire — nothing could stamp `withheld`.

  - The one-shot branch consumes the task (`/run` deletes it) BEFORE the prompt
    is even enqueued. A false precondition therefore destroyed the task without
    ever running it.

  - The manual wrapper offered only "holds" / "does not hold". The scheduled path
    treats an inability to determine the condition as NO and machine-parses a
    final verdict; here the model owned the dispatch decision, so a tool failure
    or an ambiguous answer had no branch at all and could still reach
    `create_sub_session`.

The verdict is only observable inside the session that computes it. So a manual
run no longer evaluates the guard: "Run now" means run now. The `manual` run
record and the one-shot consumption are truthful again, and there is no second
decision protocol to drift from the scheduler's.

The button says so — a guarded task's tooltip reads "Run now (runs immediately,
ignoring the precondition)", translated in en/zh.

A guarded manual run that reproduces a scheduled fire (verdict, `withheld`
stamping, one-shot consumed only on YES) would have to be dispatched daemon-side,
where the outcome exists. That is a separate change.
2026-07-10 05:56:45 +00:00
Shaojin Wen
ac2f371c44
feat(scheduled-tasks): add isolated run mode via create_sub_session tool (#6535)
* feat(scheduled-tasks): add isolated run mode via create_sub_session tool

Introduce a new `create_sub_session` tool (daemon-only) that spawns a
fresh top-level sub-session with its own clean context and transcript.
Wire it into the cron scheduler as an `isolated` run mode so each
scheduled fire dispatches its prompt into a fresh sub-session instead
of accumulating in one shared transcript.

- Add `create_sub_session` tool with `first-turn` and `sent` completion modes
- Add `SubSessionLauncher` in cli/serve with concurrency cap, timeout, and truncation
- Extend ACP bridge `extMethod` dispatch for child→daemon sub-session requests
- Add `runMode` field (`shared`|`isolated`) to DurableCronTask, CronJob, and API types
- Add run-mode radio picker to ScheduledTasksDialog UI
- Fix AuthMessage hardcoded placeholder to use i18n key

* fix(scheduled-tasks): dispatch isolated fires daemon-side, not via the model

An `isolated` fire was relayed through the model: the fired prompt was
wrapped with an instruction to call `create_sub_session`. That tool's
default permission is `'ask'`, so under `ApprovalMode.DEFAULT` an
unattended fire reached `client.requestPermission`, found no SSE
subscriber, and was cancelled by the daemon's 5-minute permission
timeout. The task never ran, and the cancel was booked as a successful
run — the headline use case of a scheduled task was broken.

Route isolated fires straight to the daemon instead: the cron `onFire`
handler in `Session` calls the sub-session spawner directly, with no
model relay and no tool-permission gate. The prompt was already approved
when the task was created; laundering it back through the model only
re-opened that gate. `create_sub_session` keeps `'ask'` for
model-initiated calls, and the attended "Run now" button keeps its relay
(a user is present to answer the prompt).

Also fix orphan-session cleanup in the launcher. `closeSession` was
guarded only by `.catch()`, which covers an async rejection but not a
synchronous throw; because the call sits inside the launcher's own
`catch (err)` block, a sync throw escaped and replaced the real launch
error. Guard both shapes.

Tests:
- Cover isolated routing: dispatch, in-session fallback with no spawner,
  missed one-shot, dispatch failure (dropped, never run inline), and
  shared mode.
- Cover the orphan close, including a `closeSession` that throws.
- Replace the sent-mode concurrency test, which only asserted the slot
  was eventually released (moving the release to the drain's *start*
  kept it green) with one that asserts the slot is HELD while the drain
  runs, plus one that asserts it is released at `turn_complete`.

* fix(scheduled-tasks): honor the caller's AbortSignal and harden the spawn boundary

Four findings from review, all in the model-initiated `create_sub_session`
path (the scheduled `isolated` dispatch reaches none of them).

`execute()` took no parameters, so it silently dropped the parent turn's
`AbortSignal`. `Session.ts` awaits `invocation.execute(signal)` without
racing the abort itself, so cancelling a turn with a `first-turn`
sub-session in flight pinned the caller's tool loop until the daemon's
5-minute ceiling. Accept the signal and return as soon as it fires.

The sub-session is deliberately NOT cancelled and deliberately KEEPS its
concurrency slot: `sendPrompt` has no abort seam, so the sub-session runs
on. Releasing its slot on cancel — as the review suggested — would let the
caller over-admit against sub-sessions that are still consuming a bridge
session and model quota.

`handleCreateSubSession` trusted the child-supplied `callerSessionId`
verbatim, and that id keys the launcher's per-caller concurrency bucket: a
fabricated id starts a fresh bucket at zero (cap evasion) and a victim's id
burns their slots (DoS). Validate it with the connection's existing
`ownsSession` seam.

Every daemon session wires a spawner, sub-sessions included, and each gets
its own cap-sized bucket — so one prompt could fan out 5ⁿ sub-sessions until
`maxSessions` ran dry. Gate nesting at one level: the launcher remembers the
sessions it spawned and refuses to spawn from them. With `callerSessionId`
now authenticated, the gate cannot be sidestepped.

Cap the prompt at 100,000 chars (matching the scheduled-task REST route) and
the display name at 200, both at the bridge trust boundary and, for the
prompt, in the tool's own validation so the model gets an actionable error.

Not changed: `create_sub_session` stays in `PermissionManager.CORE_TOOLS`.
Membership there SUBJECTS a tool to the `coreTools` allowlist; it does not
exempt it. Removing it — as the review suggested — is what would let the
tool bypass a user's allowlist, the way `agent` and `send_message` do today.

* fix(core): do not spawn a sub-session for an already-cancelled turn

`raceCancellation(spawner({…}), signal)` evaluated the spawner as an
argument, so the spawn started before the abort was ever checked. A turn
cancelled before `execute()` ran still created a sub-session on the daemon
— and it kept a concurrency slot — while the tool reported itself
cancelled.

Take a thunk instead, so the pre-abort check happens before any daemon work
is started. Track whether the spawn actually began, and say so: "cancelled
before it started, no sub-session was created" is a different fact from
"a sub-session may already have been created and is not cancelled".

Regression test asserts the spawner is never called for a pre-aborted
signal; it fails against the eager-argument form.

* fix(serve): require callerSessionId and stop misreporting an early stream close

Two findings from review.

`awaitFirstTurn`'s `'incomplete'` stopReason was unreachable. The cleanup
`finally` calls `ac.abort()` unconditionally to tear the subscription down,
so by the time the stopReason ternary read `ac.signal.aborted` it was always
true. An event stream that closed before the turn finished (bridge teardown,
WS drop) was reported as a 5-minute wall-clock `'timeout'` — indistinguishable
from a real one. Track the timer firing in its own flag.

`callerSessionId` was validated only when present. Omitting it handed the
launcher `undefined`, which minted an `anon:<uuid>` bucket — a fresh
concurrency bucket per call, so no cap — and skipped the depth-1 nesting gate
(`info.callerSessionId !== undefined && …`). Authenticating the id closed
forgery but not omission. It is now required at the bridge boundary, and
required in `CreateSubSessionInfo`, so the launcher's anonymous fallback and
the gate's presence check are both gone. Every real caller has a session id —
the tool only ever runs inside a session's turn.

* fix(serve): surface dropped fires and drain timeouts; bound sub-sessions per workspace

Three findings from review.

A dropped `isolated` scheduled fire left no trace. `debugLogger.warn` writes
nothing unless a debug log session is active, and the scheduler persists the
fire as a run before dispatch — so a nightly task could fail forever while its
history claimed it ran. It now also writes to stderr, which the daemon forwards
from the child.

A sent-mode drain that hit its 30-minute ceiling was equally silent: the catch
saw `drainAc.signal.aborted` and skipped logging, the `finally` freed the
concurrency slot, and the sub-session — which the abort does not cancel — kept
burning a bridge session and model quota. The timer now records its own firing
(the controller cannot: `finally` aborts it on every exit path) and the timeout
is written to stderr. The drain ceiling is injectable for tests, mirroring
`firstTurnTimeoutMs`.

The per-caller concurrency cap trusts `callerSessionId`, and the bridge can only
authenticate that id as "a session on this channel". Every session of a
workspace shares one child process, so nothing at the transport can prove which
of them issued the call — and a per-session secret would be readable by the
whole process anyway. Rather than pretend otherwise, add a workspace-wide
ceiling on concurrent sub-sessions that holds no matter which bucket a launch is
charged to.
2026-07-09 12:02:39 +00:00
Shaojin Wen
001d20ff26
feat(scheduled-tasks): run each task in its own dedicated, named session (#6389)
* feat(scheduled-tasks): run each task in its own dedicated, named session

Scheduled tasks created through the Web Shell management page were never firing
in the daemon-only case: the durable-cron tick runs inside an active agent
session, and the Web Shell creates a session only lazily on the first prompt, so
a task created on the management page (with no chat open) had nothing ticking it.

This binds every management-page task to a dedicated session, minted at create
time and named " <task>". The task fires ONLY inside that session — its
transcript is the task's run history — instead of via the shared per-project
durable owner. A daemon-side keepalive heartbeats those sessions so the idle
reaper doesn't stop them, and a boot-time rehydration reloads them after a
restart. Archiving, deleting, or unarchiving the session disables, removes, or
re-enables the bound task (covered on both the REST and ACP surfaces).

Also adds task editing, a live next-run countdown, run history, a one-per-row
card layout, and a "run now" that executes in the task's bound session and
updates the last-run time. All resident-session management is opt-in and enabled
only by the real daemon (runQwenServe), so createServeApp embeds/tests are
unaffected.

* fix(scheduled-tasks): address code review on per-session task feature

Review fixes for #6389:

- Distinguish archive-disabled from user-disabled tasks: disableTasksForSessions
  now marks disabledByArchive; enableTasksForSessions only re-enables tasks
  carrying that flag, so a task the user deliberately disabled stays disabled
  across an archive/unarchive cycle. [Critical]
- Rehydrate task sessions concurrently with a per-session 30s timeout so one
  hung loadSession can't stall the boot sweep or leave healthy tasks dormant.
  [Critical]
- Await runScheduledTask + reload before executing the prompt in handleRunNow,
  so a record failure surfaces and the card's "last run" reflects the trigger.
  [Critical]
- Log keepalive/rehydrate read + heartbeat failures at debug instead of
  swallowing them silently, so a persistently-failing keepalive is diagnosable.
  [Critical]
- Add integration tests: deleteDaemonSessions -> removeTasksForSessions and
  unarchiveDaemonSessions -> enableTasksForSessions (guard the coupling). [Critical]
- DELETE route: single atomic updateCronTasks that captures the bound session
  and removes the task in one cycle, closing the read-then-remove TOCTOU.
- Stop the keepalive timer during shutdown (matters for embedders that don't
  process.exit) so it can't fire against a disposed bridge.
- Deduplicate DEFAULT_BUILDER: export it once from scheduledTasksSchedule and
  drop the dialog's copy so the create form and cron-reversal can't drift.
- Reject empty-string sessionId in isValidTask: a bound task with "" would
  silently run unbound under the scheduler's truthy guard.

* fix(scheduled-tasks): isolate task sessions, fix catch-up/jitter/revive

Second review round (#6389):

- Force `sessionScope: 'thread'` when minting a task's session. The daemon's
  default scope is 'single', which attaches to (reuses) the shared workspace
  session — so a second task, or a task alongside an open chat, would bind to
  the same session, rename it, land runs in the wrong transcript, and close it
  on delete. Thread scope guarantees each task an isolated session. [Critical]
- Re-seat a recurring task's schedule anchor to now when a PATCH changes its
  cron (or flips one-shot→recurring), not just on re-enable. A bound task's
  catch-up runs on every file-watch reload, so a bare cron edit to an
  expression with an already-past slot would fire immediately on save. [Critical]
- Revive a non-resident bound session from the keepalive when its heartbeat
  fails (reaper let it go while disabled/archived, now re-enabled). Covers the
  unarchive and PATCH false→true paths uniformly and retries each interval, so
  a re-enabled task actually resumes instead of showing a live countdown that
  never fires. Best-effort, timeout-bounded, non-blocking. [Critical]
- Report `nextRunAt` using the scheduler's jittered fire time
  (`nextDurableFireMs`) instead of the bare cron boundary, so the UI countdown
  lines up with the real fire (the tick offsets each fire by up to the jitter
  window) rather than expiring early and advancing prematurely.

All four are mutation-verified. The cross-daemon double-fire on bound tasks
(same session live in two schedulers) is a separate, architecturally-invasive
fix (claim-then-fire on the durable file) tracked as a follow-up.

* fix(scheduled-tasks): sync bound session name on task rename

Create names a task's session after the task (` <name>`), but a later PATCH
that renamed the task (or edited the prompt of an unnamed task) left the
session's display name stale. The PATCH route now re-applies
`updateSessionMetadata` with the task's effective label whenever that label
actually changes — a bare cron/enabled edit does not touch the session.
Best-effort: a metadata failure doesn't fail the committed schedule change.
Mutation-verified.

* fix(scheduled-tasks): mirror run sessionId on client type; clarify server wiring

Review follow-up (#6389, qqqys):

- [Medium] `DaemonScheduledTaskRun` now mirrors the daemon's `CronTaskRun`
  `sessionId?: string`, so run-attribution the wire already sends isn't silently
  dropped by the client type (not surfaced in the UI yet; passthrough cast means
  no mapping change needed).
- [Nit] Comment the `app.locals.stopScheduledTaskKeepalive` set site, noting it
  follows the same convention as `fsFactory`/`boundWorkspace`/`acpHandle` and is
  read by the run-qwen-serve shutdown path (kept the convention rather than
  diverge to a one-off return value / declaration merge).
- [Nit] Comment the outer `.catch(() => {})` on rehydrate as intentional
  defense-in-depth (the function already handles read + per-session failures).

* fix(scheduled-tasks): couple archive/enable + record manual run only on enqueue

Two [Critical] review items (#6389, gpt-5-codex):

- PATCH re-enable coupling: reject `enabled: true` on a task disabled BY
  archiving its session (`disabledByArchive`) with 409 `task_session_archived`.
  Re-enabling it here would show an enabled task with a countdown while its
  bound session stays archived and can never fire — the caller must unarchive
  the session (which clears the marker and reloads it). A user-disabled task
  (no marker) and non-enable edits are unaffected.

- Manual "run now" ordering: record the run only AFTER the prompt is enqueued,
  not before. `runTaskManually` now returns a promise that resolves on enqueue
  and rejects if the bound session can't be opened (archived/deleted), is
  superseded, or times out; the dialog awaits it before writing
  /scheduled-tasks/:id/run, so a failed session switch no longer leaves a
  phantom run in history. Runs are serialized (one pending at a time, button
  disabled) so two quick clicks can't drop a prompt on the single bound-run
  latch. Added coverage for failed session load and double-click; all new
  tests mutation-verified.

* fix(scheduled-tasks): close dormancy/orphan/overflow gaps from review

Five items from GPT-5 /review (#6389):

- [Critical] Bind tasks to sessions only when resident management is on:
  createServeApp now passes the bridge to the scheduled-task routes only when
  `manageScheduledTaskSessions` is set. Embedders that leave it off get UNBOUND
  tasks (shared-owner firing) instead of bound tasks nothing keeps resident or
  reloads (which would silently go dormant).
- [Critical] Keep the keepalive/revive loop running whenever task sessions are
  managed, not only when a reaper is active — archiving closes a task session,
  so a re-enabled one still needs reviving with the reaper disabled. Size the
  interval under the reaper window (≤ half of it) so a small idle timeout can't
  let a session be reaped before its first heartbeat.
- [Critical] Record a manual run only after the prompt is admitted: the bound
  run latch now resolves only if `sendPrompt` admitted the prompt and rejects on
  cancellation (e.g. onSubmitBefore) / failure, so a cancelled Run now no longer
  advances lastFiredAt or appends history.
- [Critical] Clamp the dialog's reload timer to the 32-bit setTimeout ceiling
  (~24.8 days) so a months-away schedule can't overflow and spin a reload loop.
- [Suggestion] Pre-check the task cap before spawning a session, so an over-cap
  create never mints an orphan task session it must roll back.

New tests (route unbound-when-no-bridge, cap-no-spawn, computeKeepaliveIntervalMs
bounds, far-future timer clamp) mutation-verified; full server suite green.

* fix(scheduled-tasks): guard catch-up double-fire, run-now hang, /run + cron edits

Four items from GPT-5 /review (#6389):

- [Critical] Bound-task catch-up could double-fire: detection ran on every
  file-watch reload and read the stale on-disk lastFiredAt, so a reload racing
  the async catch-up persist (a foreign write to the tasks file) re-detected and
  re-fired the same overdue slot. Track ids whose catch-up was DELIVERED but not
  yet persisted (`deliveredCatchUp`) and skip re-detecting them until the write
  lands; a merely-buffered-then-dropped catch-up isn't tracked, so it still
  re-detects from disk (recovery preserved).
- [Critical] "Run now" hung the full 30s switch timeout when the bound session
  was ALREADY the current, loaded one (no dep change → the consuming effect
  never re-ran). Fire the enqueue directly after loadSidebarSession resolves as
  well as from the effect; whoever runs first nulls the latch, so it runs once.
- [Critical] POST /run recorded a run with no enabled/disabledByArchive guard,
  unlike PATCH — a direct API caller could write a phantom "ran" record onto a
  paused/archived task. Return 409 task_disabled for a disabled task.
- [Suggestion] Anchor re-seat on cron edit compared the raw string, so a
  cosmetic change (`0 9 * * *` → `00 9 * * *`) dropped a pending catch-up.
  Compare the canonical (parsed) schedule instead.

(The setTimeout-overflow and keepalive-floor reports were already fixed in
2a12cba.) New tests for the first three + the cosmetic-cron case are
mutation-verified; full core scheduler + route suites green.

* fix(scheduled-tasks): block disabled-task run in UI; record manual run at admission

Two [Critical] review follow-ups (#6389):

- A disabled task could still EXECUTE from the Web Shell: the Run button was
  only gated on `runningTaskId`, so clicking it enqueued the prompt and the
  server's `/run` `task_disabled` guard merely refused the later history write —
  a real, unrecorded run. Gate `handleRunNow` and disable the button on
  `!task.enabled` too, so a disabled task's prompt is never enqueued.
- Manual run recorded only after the whole turn: the bound-run latch resolved
  via sendPrompt, which completes through waitForAcceptedPromptCompletion, so a
  long/permission-blocked run or a closed tab could execute without ever being
  recorded. Add an `onAdmitted` callback to sendPrompt (fired when the daemon
  accepts the prompt, before the turn) and resolve the manual-run latch at
  admission instead — cancellation before admission still rejects.

New dialog test (disabled task → no enqueue) mutation-verified; webui/web-shell
typecheck + existing session-action tests green.

* fix(scheduled-tasks): guard tick double-fire, cap rehydration, harden lifecycle writes

Review follow-ups (#6389):

- Extend the fire-persist re-detection guard to ON-TIME tick fires, not just
  catch-ups (renamed deliveredCatchUp → firePersistPending): a bound task fired
  by the tick advances lastFiredAt asynchronously, so a reload racing that write
  (bound detection runs every reload) could re-detect the slot and double-fire.
  The tick persist now adds its ids to the guard and clears them when the write
  lands, symmetric to the catch-up persist.
- Bound boot-rehydration concurrency (batches of 4): each loadSession forks a
  child, so loading up to 50 at once spiked the host and risked spawn failures
  that strand tasks. The keepalive revive path was already sequential.
- Archive disable failure is now logged (was fully swallowed) so a broken
  archive→pause coupling — where the keepalive would revive the just-archived
  session — is diagnosable.
- Unarchive re-enable failure is surfaced in the result `errors` and logged, and
  enableTasksForSessions also runs for already-active sessions — so a task left
  stranded ({enabled:false, disabledByArchive:true}) by a prior failed enable is
  recoverable by re-unarchiving, instead of being permanently stuck.
- Create rollback now removes the persisted session (close + removeSession), so
  the loser of a concurrent create at the cap boundary (passes the pre-check,
  loses the authoritative write) doesn't leave an orphan named session.

New tests (tick-fire guard, bounded rehydration, already-active recovery)
mutation-verified; full core scheduler + serve suites green.

* fix(scheduled-tasks): one-shot run/edit correctness; tick persist non-regression

Review follow-ups (#6389, ci-bot):

- [Critical] Manual /run on a ONE-SHOT task now removes it from the store. Its
  slot is still in the future, so stamping lastFiredAt=now didn't stop the
  scheduler firing it again at its original time — a double run. A one-shot's
  manual run IS its single fire, so the task is spent.
- [Critical] PATCH recurring:false now re-seats the one-shot's createdAt anchor.
  The old (long-past) anchor made the scheduler read it as a MISSED one-shot and
  fire + permanently delete it. Re-seating createdAt points its next fire at the
  upcoming occurrence. Also covers a cron edit on an existing one-shot.
- [Suggestion] The tick persist no longer regresses lastFiredAt: it skips the
  write when the on-disk stamp is already >= the tick slot (a concurrent manual
  /run or catch-up may have stamped newer), mirroring the catch-up persist guard.
- [Suggestion] Added the missing create-rollback test: a post-spawn commit
  failure closes AND removes the minted session (no orphan).

New tests (one-shot run removal, recurring→one-shot re-seat, rollback teardown)
mutation-verified; core scheduler + route suites green.

* fix(scheduled-tasks): ref-count fire guard, real rehydration cap, authoritative run check

Four [Critical] review follow-ups (#6389):

- Ref-count firePersistPending (was a boolean Set): the same task can have two
  lastFiredAt persists in flight (fired again before the first write landed);
  clearing on the first settle dropped the guard while the second was still
  pending, re-opening the double-fire window. The count holds it until the last
  persist settles.
- Rehydration concurrency is now enforced on the REAL loads: loadSession isn't
  abortable, so a timed-out load kept forking in the background while the next
  batch started. A bounded worker pool holds each slot until the underlying load
  actually settles, so in-flight child spawns never exceed the cap.
- Unarchive recovery reports failures for the full resume set: it enables both
  unarchived AND already-active sessions but only logged/returned errors for
  unarchived, so a failed already-active recovery surfaced errors:[] and left a
  task stranded. Deduped one list used for the call, log, and errors.
- Manual "run now" re-checks server-authoritative state before enqueuing: the
  dialog snapshot can be stale (another tab/API disabled/deleted the task), so
  it would execute the prompt and only the /run record would 409. It now
  refreshes, bails if gone/disabled, and enqueues the FRESH prompt/session.

New tests (ref-count, slot-held-past-timeout, stale-disabled re-check)
mutation-verified; core scheduler + serve + dialog suites green.

* fix(scheduled-tasks): catch-up non-regression, disabled-edit re-seat, run/timer/keepalive hardening

Review follow-ups (#6389):

- [Critical] Catch-up persist no longer regresses lastFiredAt: use `>=` like the
  tick persist, so a newer stamp (a cross-process manual /run) landing while the
  catch-up write is in flight isn't overwritten back to the older minute.
- [Critical] The PATCH anchor re-seat now runs for schedule edits even while the
  task is disabled — editing a disabled one-shot's cron then re-enabling it (two
  separate requests) no longer leaves a stale anchor that fires + deletes it.
- [High] Manual "run now" of a bound ONE-SHOT consumes it server-side (/run,
  which deletes) BEFORE enqueuing, so a record failure leaves a recoverable
  "recorded but never ran" instead of a silent double execution at its slot.
- [Medium] The dialog reload timer backs off a stuck past-due nextRunAt (fast
  reloads to catch a just-fired advance, then a slow lane) instead of spinning a
  1 Hz GET loop.
- [Medium] The manual-run latch bounds the admission phase with a timeout, so a
  send that wedges before admission degrades to a visible "run failed" instead
  of freezing the run controls.
- [Suggestion] Keepalive: an in-flight guard skips a tick while the previous
  pass runs (no duplicate concurrent loadSession spawns), and per-session
  exponential backoff stops retrying a permanently-gone session every interval.

New tests mutation-verified. Two deeper items (a task session winning the
durable lock and firing unbound tasks; tearing down a consumed one-shot's
session) are left open as tracked follow-ups — both need new daemon↔child
infrastructure.

* fix(scheduled-tasks): one-shot anchor on unarchive, memoize next-fire, sanitize + log

Review follow-ups (#6389):

- [Critical] enableTasksForSessions now re-seats a ONE-SHOT's createdAt anchor
  (not just recurring's lastFiredAt) on unarchive — otherwise unarchiving a task
  that was converted to recurring:false while disabled fires it as a missed
  one-shot and permanently deletes it.
- [Critical] Log the DELETE-path removeTasksForSessions failure (was fully
  swallowed) like the archive/unarchive paths — the session is already gone, so
  a silent write failure leaves the still-enabled bound task a permanent ghost.
- [Medium] Memoize nextDurableFireMs (deterministic per id/cron/recurring/anchor)
  — a sparse cron costs hundreds of ms per scan and the route recomputed it per
  task on every request, stalling the event loop for 50 yearly tasks.
- [Nit] The consumed one-shot /run response now nulls nextRunAt (it was
  advertising a future fire on an entity the next GET omits).
- [Suggestion] scheduledTaskSessionName strips terminal control sequences (the
  bridge title guard rejects them → silently drops the rename) and truncates on
  a code-point boundary (no lone surrogate broadcast as U+FFFD).
- [Critical/doc] Document that firePersistPending is instance-scoped — the
  narrow cross-instance restart window is an accepted edge.
- Added the missing test for editing an enabled one-shot's cron.

New tests mutation-adjacent; suites green. Two deeper items (session deleted
outside the daemon orphaning a bound task; surfacing bound tasks in cron_list)
are left open as tracked follow-ups.

* fix(scheduled-tasks): re-seat one-shot anchor on re-enable; guard duplicate revive

Two [Critical] review follow-ups (#6389):

- Re-enabling a one-shot now re-seats its createdAt anchor (added justReEnabled
  to the one-shot branch). A one-shot disabled past its slot then re-enabled was
  otherwise read as a missed one-shot on the next reload — fired immediately and
  permanently deleted. Updated the prior "leaves anchor untouched" test to the
  safe behavior (fires at next occurrence).
- Keepalive revive no longer spawns a duplicate child: loadSession isn't
  abortable, so a timed-out revive keeps running; a later tick (past its backoff)
  would start a SECOND load for the same session. An in-flight `reviving` set
  (cleared on the load's TRUE settlement, not the timeout) blocks that — without
  holding the sequential tick, so other sessions' heartbeats aren't delayed.
  Added a configurable reviveTimeoutMs for the test.

Both mutation-verified. (The one-shot /run session teardown raised again is the
same item as the open deferral — a synchronous close there would break the run,
which executes after /run; it's tracked for the keepalive orphan-sweep.)

* fix(scheduled-tasks): strip bidi override/isolate chars from session name

The bridge's title guard (hasControlCharacter) only rejects C0/DEL, so
Unicode bidi override/embedding/isolate controls (U+202A–202E, U+2066–2069)
slip past it and can visually reorder a scheduled-task session name in the
session list — a Trojan-Source-style attack (CVE-2021-42574). Strip them
alongside the existing terminal-control-sequence pass, matching core's
stripDisplayControlChars canonical set.

Adds a test built from code points so the test file itself carries no
reordering controls.

* fix(scheduled-tasks): close review findings — rehydrate deadlock, manual-run recording, shared helpers, tests

Addresses the review findings on the per-task-session work:

- keepalive rehydrate no longer awaits a non-abortable loadSession after its
  timeout. A genuinely hung load would pin its worker and, with enough hangs,
  wedge the whole boot sweep (Promise.all never settles) so later task sessions
  never rehydrated. The worker now records the timeout as failed and pulls the
  next queued session; the background load is left to settle. Rewrote the test
  that pinned the old "hold the slot" behavior into a no-wedge regression guard.
- web-shell manual run drops its pre-admission timeout. sendPrompt isn't
  abortable, so rejecting on the timer while the send was still in flight let a
  LATE admission execute an UNRECORDED run the user could retry into a
  duplicate. The run is now tied to admission (accepted prompts are always
  recorded); the "session never becomes active" phase stays bounded by the
  switch timeout in runTaskManually.
- extract collectBoundSessionIds() shared by the heartbeat + rehydrate passes
  (was duplicated) and isBoundTask() in the lifecycle module (was the lone
  `sessionId !== undefined` check vs. the strict one used everywhere else).
- spell the nextDurableFireMs cache-key separator as `\x00` rather than a
  literal NUL byte, so cronScheduler.ts no longer reads as binary to ripgrep.
- add App.test coverage for the manual-run orchestration (admission-resolve,
  cancel/error reject, immediate fire, supersede, switch timeout) and a
  keepalive test that a disabled task gets no heartbeat and no revive.

* fix(web-shell): "create via chat" opens a fresh session in scheduled tasks

The scheduled-tasks "Create via chat" button switched to the chat view but
stayed on the CURRENT session, piling the task-creation conversation onto
whatever the user was already doing. It now starts a new session first
(createNewSession) and jumps to it before priming the composer, so task
creation gets its own chat. Covered by a new App.test case asserting
clearSession() is called.

* fix(scheduled-tasks): address follow-up review findings

- keepalive rehydrate: guard the onError callback with try/catch. If it threw
  (e.g. stderr EPIPE during log rotation) the rejection escaped loadOne, failed
  its worker, and short-circuited Promise.all — stranding every other queued
  session.
- cronScheduler catch-up: use the strict `typeof sessionId === 'string' &&
  length > 0` bound-check instead of `!== undefined`, matching every other
  "is bound?" site.
- server rehydration: log the outer defense-in-depth catch instead of swallowing
  it, so an unexpected throw isn't a silent "tasks never fire".
- session-name sanitizer: also strip the standalone Bidi_Control marks U+061C /
  U+200E / U+200F, not just the override/isolate ranges.
- scheduled-tasks dialog: when a consumed one-shot then fails to deliver, show a
  specific "deleted but never ran — recreate it" error instead of the generic
  "run failed" that hid the deletion. Kept the deliberate consume-first ordering.

* fix(web-shell): don't prime the composer when "create via chat" can't start a new session

onCreateViaChat's deferred composer-priming ran unconditionally: if
createNewSession() failed, the task-starter text was dropped into the CURRENT
session (only onSessionIdChange was gated on success). Gate all post-create
side effects on `created`, matching handleMissingSessionNewSession. Adds an
App.test failure-path case (new session fails → composer not primed).

---------

Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
2026-07-07 06:22:36 +00:00
Shaojin Wen
9a63c03224
feat(web-shell): add a Scheduled Tasks management page (#6348)
* feat(web-shell): add scheduled tasks management page

Add a "Scheduled tasks" page to the Web Shell for managing durable cron tasks against the current workspace.

- Sidebar entry opens a full-pane page (replaces the chat area, not a modal) listing tasks with enable/disable toggle, delete, run-now, and human-readable schedules.
- "New scheduled task" opens a modal with a schedule builder (daily / weekdays / weekly / hourly / every-N-minutes / custom cron) and a live preview.
- "Create via chat" returns to the chat and primes the composer so the agent creates the task through its cron_create tool.
- Daemon CRUD routes (GET/POST/PATCH/DELETE /scheduled-tasks) read/write the existing per-project scheduled_tasks.json; task firing stays with the session-side scheduler.
- Extend DurableCronTask with optional name/enabled (backward compatible); the scheduler skips tasks with enabled:false.
- Add /scheduled-tasks to the vite dev-server proxy allowlist so the page works under npm run dev:daemon.

* chore(web-shell): address review feedback on scheduled tasks

- cron_list: surface name/enabled so the agent can tell a disabled durable task from an active one (a disabled task no longer looks identical to an active one).
- core: export only the tasks-file functions the daemon route actually uses (drop unused addCronTask / getCronFilePath / CRON_TASKS_DISPLAY_PATH from the public barrel).
- CronScheduler: warn when a durable reload fails and the prior view is kept, since a just-disabled or -deleted task can keep firing until the next successful reload.
- Extract the schedule helpers (buildCron / describeCron / parseHhmm / describeLastRun) into a pure module and add unit tests for them.
- Add route tests for PATCH cron/prompt/recurring, empty-patch rejection, and POST field-length / boolean-type validation.

* chore(web-shell): address second review round on scheduled tasks

- Log CRUD errors server-side (writeStderrLine) in each route catch block, matching the other daemon routes.
- Share one id generator (generateCronTaskId in cronTasksFile) between the scheduler and the daemon route instead of duplicating it.
- describeCron: recognize cron day-of-week 7 as an alternate notation for Sunday.
- Reset the builder time to :00 when switching to the hourly frequency (its time picker is hidden, so it no longer silently carries the daily minute).
- Tests: cron_list name/disabled output; route Feb-30 impossible-cron and corrupt-file 500 read-failure; describeCron dow=7.

* chore(web-shell): address third review round on scheduled tasks

- Run now: report sendPrompt rejections via the toast/error path instead of dropping the promise.
- Block chat interaction while the full-pane Scheduled Tasks view is open, so the covered composer can't receive keystrokes/Escape.
- Guard reload() with a request-sequence id so a slow load can't overwrite a newer list after a mutation.
- Re-enabling a task that had genuinely fired resumes from now instead of catching up work paused while it was disabled.
- Restrict "every N minutes" to divisors of 60 (a non-divisor */N fires more often than the label claims).
- Show a Repeats / Runs once label on each card so tool-created one-shots aren't mistaken for repeating schedules.
- Return generic 500 client messages (no internal file path); the detail is logged server-side.
- Tests: SDK scheduled-task methods (method/URL/id-encoding/headers/errors); route re-enable behavior both ways.

* chore(web-shell): address fourth review round (minor suggestions)

- Route error logs interpolate the actual task id instead of the literal ":id".
- cron_list returnDisplay includes the task name (matching llmContent) so terminal /cron list shows UI-assigned names.
- Truncate the delete-confirm label so an unnamed task's long prompt doesn't blow up the confirm() dialog.
- Cap the create-form prompt textarea at MAX_PROMPT_LENGTH and drop the dead typeof-window guard.
- Test generateCronTaskId (format + near-uniqueness).

* chore(web-shell): address fifth review round on scheduled tasks

- Re-enable now resumes any recurring task from now (stamp on every false→true), not only ones that had already fired — a task disabled before its first run no longer catch-up-fires the slot it was paused through.
- describeCron applies the same divisor-of-60 check as buildCron, so a hand-edited/persisted */45 falls back to the raw expression instead of a misleading "every 45 minutes".
- Strengthen the corrupt-file route test to assert the generic client message and no leaked file path.
- Tests: recurring-disabled-before-first-run and one-shot re-enable; describeCron non-divisor fallback.

* test(cli): cover legacy scheduled-task normalization on GET

Seed a pre-fields task (no name/enabled) directly to disk and assert the GET response normalizes it to name:null / enabled:true, guarding backward compatibility with existing scheduled_tasks.json files.

* fix(core): cap durable cron loads against a durable-only budget

The daemon route accepts up to MAX_JOBS durable tasks on disk, but the scheduler previously capped durable loads against its combined job map (session-only + durable). A session holding session-only cron jobs could push the map to MAX_JOBS and make loadFileTasks silently skip durable tasks the route had already accepted — a create that returned 201 would then never fire.

Cap durable installs against a durable-only count instead, and share one MAX_JOBS constant between the scheduler and the daemon route, so a successful create is always loadable. Adds a scheduler test that 40 session-only jobs no longer crowd out 20 durable loads.
2026-07-06 03:47:17 +00:00