Skip to content

Latest commit

 

History

History
753 lines (662 loc) · 45.8 KB

design-background-work.md

File metadata and controls

753 lines (662 loc) · 45.8 KB

Design: Background Work — agent-detachable Services, Long Tasks, and Cron

Status: IMPLEMENTED 2.342.0 (2026-08-17, owner-approved; per-machine only — cross-machine execution parked pending owner design). This doc remains the authoritative spec; deferred residue: remote hostId, resource caps, --publish deep wiring, port-sweep suppression, taskbar badge segment, Session Properties row, systemd/crontab one-click import, panel tier-2 iframe. Session Properties: the owned-jobs list landed (B-70f9 ②, 2026-10-02); the kill dialog half is open. v1→v2: three-lens red team (40 findings, 5 critical) incorporated. v2→v3 (owner decisions 2026-08-17): ① services must survive a pod rebuild via boot replay — that is sufficient; long tasks need NOT survive pod rebuilds. ② No automatic agent triggering from cron/task completion — the automation red line stands (the only conceivably-acceptable future form would be an explicitly-metered claude -p run on an API-key account; deferred indefinitely). Instead, job events reach a conversation as passively injected context at natural turn boundaries, byte-budgeted. ③ Agents obtain results primarily via poll, not wakeups. ④ New: Interaction Panels — an agent running a user-interactive long task (verification codes, scan-QR-then-enter) can present a simple UI to the user; the definition mechanism is §6.4. Evidence base and archetypes: §2 (mined from 2.1 GB of this machine's agent history; private verbatim evidence in the personal-group shared context).


1. Problem statement

Agent work constantly produces processes and obligations that must outlive the conversation; every VibeSpace primitive today is session-bound. Observed workarounds (all real, recurring): nohup/setsid roulette (leaks — the 12 GB orphan dev-server incident behind B-16d9); escapes to systemd --user + crontab (a daily agent automation hand-built as timer + retry script + chat-mirror, with secrets baked into unit files and 4 silent failures in 3 weeks); sessions parked as pollers (CI watchers re-armed by hand a dozen times); future-dated backlog items as poor-man's cron; results landing in /tmp with no context after the launcher compacted; pkill -f matching the agent's own cmdline (twice); and post-restart recovery by human broadcast because no inventory exists.

Kind One-liner Lifecycle
Service "keep this process up" boot replay (incl. across pod rebuilds), restart policy, health check, no completion
Long task "run to completion; I may be gone by then" one-shot, interruptible, pollable, carries a context payload echoed at poll time; dies with the pod (accepted)
Cron "do X on a schedule / at a date" fires an action: spawn a task or notify the user (никогда an agent)

2. Use-case archetypes (mined, deduplicated)

Services — (1) dev server for verification, findable/stoppable after the conversation; (2) serve-to-the-outside (test HTTP servers, webhook/OAuth targets — wants stable published URL + liveness); (3) shared auxiliary infra (headless- Chrome rig, mock backends, VNC stack — "start if absent, reuse otherwise"); (4) always-on watchers with remediation (a tunnel silently dead 9 days); (5) interactive background process holding stdin across turns → now §6.4.

Long tasks — (6) multi-hour convergence watches; (7) batch/collection jobs where overlapping restarts corrupted output (single-instance locking); (8) await-external-event (human QR scan, endpoint healthy — unbounded, never silently expires); (9) poll-later with amnesia: the poller needs the original intent — the context payload — echoed with the result.

Cron — (10) recurring patrols (quiet-success, escalate-only notify); (11) future-dated one-shots weeks out; (12) periodic maintenance. (Scheduled agent runs are explicitly out of scope — red line, §3.)

Cross-cutting — registration-first (port-sweep discovery stays as the stray fallback); kill by handle only, re-verified at kill time; per-run history with retained logs and exit causes; boot = adopt-first; first-class visualization.

3. Hard fences

  • No automatic agent triggering. (owner decision, v3) Cron actions and task completions never start or wake an agent. The passive channel (§6.3b) only injects awareness into a conversation's next natural turn. Rationale: an unattended agent run on a subscription identity is the automation red line (ban postmortem); the only future-acceptable form would be an explicitly metered claude -p on an API-key account, and that is deferred indefinitely — it is NOT part of this design's M1–M3.
  • §ban-safety by policy + guardrails (red team: structure alone cannot see inside stored argv): refusal of every command a job can run — argv/env/cwd/ health-cmd (a named envFrom is refused by name since 2.369.365, §7), a schedule's action.task included (B-f8c7) — at create, at start and at every spawn (the one vet, vetSpec), matching an obvious read of a subscription sign-in (owner decision 2026-10-03, verify r3: every harness descriptor's account dir, auth file and credential-dir variable, the keychain item, OpenCode's login file, CLAUDE_CODE_OAUTH*, pasted sk-ant-oat… / sk-ant-ort…; test-job-model censuses the descriptors against the list); an API call with the job's own key runs — no vendor-host or timer rule; a reminder against obvious reads, not a sandbox; schedule floors (default 15 min for agent-created recurring) + mandatory jitter; health probes run through the same sanitized async spawn path (agentEnv-stripped, hard timeout, group-kill) with jittered intervals; negative-control tests (a vendor-credential job spec must be refused).
  • Not a plugin replacement; not a distributed scheduler in v1 (hostId is a parameter from day one, local-only M1); does not replace the goal loop or harness background Bash (§8 teaches the negative space).

4. Data model

Store data/jobs.json (writeJsonAtomic, SIGINT/SIGTERM flush, mutations broadcast jobs-updated), per-run logs data/job-logs/<jobId>/<runTs>.log.

{
  "id": "jb-3f9a2c",
  "kind": "service|task|cron",
  "name": "collect-x",              // unique per OWNER SCOPE (not global): collision
                                     // auto-suffixes with a warning; ids are the only
                                     // global handle (no existence oracle / squatting)
  "note": "…", "hostId": null,       // null = this instance; machine id in M2

  "cmd": { "argv": [...], "cwd": "/abs", "env": { /* NON-secret; values render to owner/user only, never in jobs-updated payloads */ } },
  "envFrom": [],                     // RETIRED (2.369.365, §7): records keep the empty list; a named one is refused `envfrom_retired`

  // service
  "restart": "on-failure|always|never",
  "health": { "type": "port|http|cmd", "value": "8088", "intervalS": 60 },  // jittered, sanitized, harmless-probe law
  "ports": [8088], "publish": false,

  // task
  "singleInstance": true,            // per-RECORD concurrent-run lock; enforcement re-verifies
                                     // liveness of the last run's process, never record state alone
  "timeoutMs": null,
  "untilOutput": "COLLECTED",        // literal substring in M1 (regex only via linear-time vet);
                                     // matched against bounded appended tail chunks only (§5)

  // cron
  "schedule": { "cron": "41 9 * * *" } | { "everyMs": 1800000, "jitterPct": 20 } | { "at": "…" },
  "catchUp": "once",                 // default once for {at}; a passed {at} under catchUp:none
                                     // goes terminal `missed` AND fires notify (no silent expiry)
  "expiry": null,
  "action": { "type": "spawn-task", "task": { /* embedded task spec */ } }
          | { "type": "notify", "text": "…", "urgency": "low|normal|high" },
                                     // v3: wake-* actions REMOVED (red line)

  "context": { "payload": "…" },     // cap 8 KB (fits the 9600 B inline injection budget after
                                     // wrapping; CJK-safe truncation); echoed on single-job poll;
                                     // `list` shows first line + byte count

  "interaction": { "pending": null, "answers": [] },   // §6.4 panel state

  // ownership: keyed to CONVERSATION LINEAGE (resume mints a new webui session id)
  "owner": {
    "conversation": { "backend": "claude", "id": "<backendSessionId>" },   // + fork-chain match
    "sessionId": "…", "sessionCreatedAt": 0,   // secondary, collision-proof tuple
    "createdBy": "agent|user",
    "groupsSnapshot": ["T-…"]        // refreshed eagerly on every mutation + periodic sweep
  },
  "access": { "view": "group", "control": "session", "lockedBy": null },
                                     // agent defaults: view=group (fallback all when
                                     // ungrouped), control=session; user locks stick (§7)
  "stopWithOwner": false,

  "desiredUp": true,
  "state": "up|down|starting|failed|done|interrupted|unverified|scheduled|missed|awaiting-user",
  "proc": { "pid": 0, "starttime": 0, "bootId": "…", "argvHash": "…" },
                                     // identity = pid+starttime+bootId; argv-hash secondary only
  "supervise": { "consecutiveFails": 0, "parkedAt": null },   // persisted; boot skips parked
  "runs": [ { "startedAt":0,"endedAt":0,"exit":143,"cause":"ok|ok(until-output)|error|interrupted|oom|timeout|env-restart|missed","log":"…","trigger":"manual|boot|cron|restart-policy" } ]
}

5. Engine

Tier routing: PURE src/job-model.js (schedule math, jitter, state machine, permission predicates, vendor-pattern vet, panel-schema validation) · ORCH src/jobs.js + server.js wiring stanza · agent endpoints in agent-routes.js · REST /api/jobs* · CLI data/bin/vibespace-job (static tracked, AGENT_TOOLS) · CLIENT src/lib/jobs-panel.js (+ src/lib/job-interact.js §6.4) · M2 DEVICE job-* agentd ops.

  • Single-engine lock: data/jobs.lock (pid+starttime, stale-safe) before adopt/replay; a second server process (the #127 class) gets read-only view + loud notice — never adopts, replays, or fires crons.
  • Failure isolation: engine init after listen, try/catch; endpoints 503 until engine-ready; ANY init failure files a persistent user notice + panel banner. Per-job try/catch. Crash loops: success = uptime ≥ 60 s, backoff 5 s→10 m, cap 6 → park failed + notify; consecutiveFails/parkedAt persist; boot replay skips parked services. Store corruption: rename .corrupt-<ts>, reconcile from pidfiles + log-dir names into unverified records, block same-name re-registration until resolved, salvage-parse best-effort.
  • Process identity & spawn: intent-before-spawn (starting + pidfile path flushed); setsid-detached via a tiny wrapper whose first act writes its own pidfile {pid, starttime, bootId, argvHash}; boot reconciles pidfiles + declared-port liveness BEFORE replay decisions. Adopt and every kill re-verify the full stamp at act time. Tasks are adopt-first too; non-adoptable-but- possibly-alive parks unverified, never terminal-while-running. The wrapper's spec (argv, cwd, env — the secrets and the jbt_ token) rides a 0600 file in the run's 0700 dir, never argv: spec.<runTs>.json, O_EXCL, read once and unlinked; the wrapper's own env is an allowlist and a spec without env is refused (lane job-spec-off-argv, 2026-10-20 — until then the spec rode base64 on argv, readable in /proc//cmdline).
  • Survival promise (v3, simplified per owner decision): services survive ANY restart — same-process-tree restarts by detach+adopt, pod rebuilds by boot replay (respawn from spec; that is the accepted semantic). Long tasks survive server restarts where detach holds (systemd/bare) and are honestly marked cause:"env-restart" + owner notified when a pod rebuild kills them — no checkpoint magic promised. CLI/panel state the per-runtime promise.
  • untilOutput mechanics: per-job byte-offset cursor, incremental appended-tail reads with per-tick byte budget, per-line length cap; M1 literal substrings; match → grace 30 s → SIGTERM group → ok(until-output).
  • GC: age-based only; never GC a record whose stamp verifies alive; never unlink a non-terminal run's log (rotate via wrapper reopen, don't unlink).
  • Ports integration: registered ports suppress the "new port" toast (service row instead; annotation waits on engine-ready); orphan detector consults the registry; orphan toast gains "Track this" (adopts as restart:never health-only, "restart command unknown" — /proc can't reconstruct env/venv); --publish composes with forward/frp for stable URLs.

6. Semantics

6.1 Service

Register → desiredUp:true → supervised start. stop always sets desiredUp:false (resurrection needs explicit start — no stop-vs-disable footgun). Health probes: sanitized env, async, timeout, jittered.

6.2 Long task

Created via run; id returned immediately. Poll (non-blocking) returns state + exit cause + payload + bounded log tail. --wait long-poll: server clamp 600 s, waiter caps per job/token, resolve-with-state on shutdown; CLI caps --wait at 100 s (under the harness Bash 120 s default) and traps termination to print current state. Interrupt = verified kill, graceful→hard. Progress via vibespace-job progress (job env carries VIBESPACE_JOB_ID). Completion is learned by polling (owner decision); §6.3b's passive injection additionally tells the conversation at its next natural turn.

6.3a Cron actions (v3: two only)

  • spawn-task — embedded task spec, full task semantics.
  • notify — inbox item carrying the job id, one-click pause/delete; per-job rate cap + dedupe window (identical text collapses with a counter); agent-set high on a schedule auto-downgrades after the first unacked repeat. Schedules jittered with floors (§3); catchUp:"once" make-up runs are marked trigger:boot; a missed {at} under catchUp:none goes missed + notify — no silent skip exists anywhere.

6.3b Passive context injection (replaces wake; owner decision)

Job events for jobs owned by (or visible to) a conversation — finished, failed, parked, awaiting-user — queue per conversation and are delivered ONLY at natural injection points, through the existing prompt-context path (the pendingNotice / task-context channel agent-routes already budgets):

  • SessionStart / first prompt: the jobs digest — one line per visible job, failed and awaiting-user first: jb-3f9a task done 2h ago — collect-x — vibespace-job poll jb-3f9a.
  • Next user turn (UserPromptSubmit): only NEW events since last delivery, one line each.
  • Budget management (owner concern; PROTOTYPE-VALIDATED 2026-08-17): the jobs section is byte-capped (default 600 B) with tail-first truncation to a …+N more — vibespace-job list pointer and a degenerate one-line floor (## Background jobs: N — vibespace-job list, 44 B); name display is code-point-clipped at 24 chars (CJK-safe — never a split multibyte char). Placement: inside the existing SessionStart/prompt-context payload, after tools/rules and group context, BEFORE activity logs; sacrifice order when the global INLINE_CAP (9600 B, margin under the harness's exact-10240 persisted-output wrap) is tight: activity logs shrink first, then the jobs digest folds to its floor line, then drops entirely — group context is never displaced. Measured worst cases (prototype, seeds test-job-model): 40 jobs with max-length CJK names → 558 B; 200 adversarial jobs → 547 B; 7-event update block → 569 B; merged into a 9350 B existing payload → 9396 B (floor applied), 9520 B → 9566 B, 9580 B → digest dropped; zero jobs/events ⇒ zero bytes. Markers advance at render; delivery re-checks VISIBILITY at render time (an access narrowed after an event queued is honored). No mid-turn injection, no autonomous turns, no Stop-hook extension: an idle conversation learns about job events when the user next engages it, or the agent polls — poll is the primary interface (owner decision).

6.4 Interaction Panels (agent-authored UI for user-interactive tasks)

The mined cases: enter an SMS/2FA code, scan a QR then type the resulting code, choose an option mid-task, approve a step. Design principle: declarative widgets, never agent HTML in our DOM (stored-XSS law).

Tier 1 (this design): declarative panel schema. The agent (or the running job itself) posts a panel:

// vibespace-job ask <id> --form '<json>'   (or --form @file.json)
{
  "title": "扫码登录",
  "blocks": [
    { "type": "md",     "text": "手机扫下面的二维码,然后把短信验证码填进来。" },
    { "type": "image",  "path": "/tmp/qr-login.png" },      // served via the file API; path access-checked against the job cwd/allowlist
    { "type": "input",  "id": "code", "label": "验证码", "placeholder": "6 位数字", "pattern": "\\d{6}" },
    { "type": "choice", "id": "env",  "label": "环境", "options": ["prod", "staging"], "default": "prod" },
    { "type": "buttons","options": [ { "id": "submit", "label": "提交", "style": "primary" },
                                     { "id": "cancel", "label": "取消" } ] }
  ],
  "timeoutS": 1800                    // panel expires → job sees {expired:true}
}

Widget set (M1): md (DOMPurify-sanitized markdown), image (file-API path, .src assignment never innerHTML), input, textarea, choice (radio/ select), checkbox, buttons, progress (read-only bar the job can update). Validation (pattern, required) runs client-side AND server-side (PURE schema validator in job-model). Schema size cap 32 KB; one pending panel per job (a new ask replaces it, versioned so a stale submit is rejected loudly).

Flow: job posts panel → job state:"awaiting-user" → amber badge segment + toast + inbox item ("collect-x 需要你输入 — 打开面板") → the panel renders in a small window (window type job-interact, openSpec-replayable, mobile-friendly since blocks stack vertically) → user submits → answers append to interaction.answers → the job reads them: vibespace-job answers <id> [--wait 100] (same long-poll mechanics as §6.2) or, if the job was started with --stdin-open, each answer is also written to the job's stdin as one JSON line (covers curses-less simple scripts). Multi-round: post another panel. Everything renders escaped; the user's answers are visible to owner/user only (they may contain codes — never echoed in list).

Tier 2 (M3, only if tier 1 proves insufficient): sandboxed HTML app — agent writes an HTML file rendered in <iframe sandbox="allow-scripts"> (no same-origin, no cookies) with a tiny postMessage bridge (vsui.submit(obj), vsui.progress(p)); our DOM never touches agent markup. Deferred.

7. Permissions & secrets

  • view/control ∈ session|group|all; agent defaults view=group (fallback all when ungrouped), control=session. Owner = conversation-lineage match, verified on the fast path; mismatch ⇒ surfaced for re-owning in the panel.
  • Mutation ≠ control: editing cmd/env/action/schedule/panel = owner or user only; group/all control covers start/stop/kill/ack only (no cross-session prompt/argv injection).
  • User locks stick: lockedBy:'user' refuses agent access-edits (teaching error) and survives rm+recreate via a (scope,name) tombstone (30 d).
  • Secrets, honest threat model: same-UID processes can ultimately read anything — the defence is against ACCIDENT roads (a value shown in a transcript, a tool's output, a log, env, argv, a file, a card), not against a process that wants the value. A job's secret comes from the vault only (design-vault-service-secret; lane job-secrets-retire, 2.369.365): the old road — envFrom names resolved at spawn from a PLAINTEXT 0600 data/job-secrets.json (written by a cookie route no client ever called) into the job's ENV, and literal-redacted from log tails by re-reading that file — is RETIRED. A spec naming envFrom (itself or its action.task) is refused envfrom_retired («a job's secret comes from the vault: --vault NAME=vault://… — see vibespace-docs jobs») at every door the one vet runs: create, start, and every spawn (a keep-up restart, a boot replay, a cron's fire). GET/POST /api/jobs-secrets are gone (404) and nothing reads the file; one an owner once filled is not left in silence (r2): the one-shot 2026-10-job-secrets-retired RENAMES it to data/job-secrets.retired.json (0600, kept — VibeSpace never deletes it; a SECRETS_AT_REST row) and files ONE For-you notice under Vault naming the file, its names (never a value; a name shaped like a token is counted, never shown — r3) and the recipe (each into the vault, then delete the file). Records keep envFrom: [] and run unchanged (no record migration); show prints envFrom:[…] only for a record that still names one. cmd.env values render to owner/user only. M2 remote jobs get a security subsection before implementation (device-side 0600 files over the mux never argv; no subscription-account material in remote job env; watch re-arm on daemon reconnect as a three-touch assert).
  • No existence oracle (agent must never learn about jobs it cannot view): every agent-facing read path filters by view BEFORE rendering — the session-start digest, prompt-context updates, list (counts never include or hint at hidden jobs), and name-collision handling (names are scope-namespaced precisely so a collision error can only ever reference the caller's own jobs). poll/logs/stop on a non-visible id return the IDENTICAL reply as a nonexistent id — no job "jb-x" visible to this session — vibespace-job list — never a 403 that confirms existence. jobs-updated WS broadcasts carry full data but reach cookie-authed browser clients only (agents hold no WS; their surfaces are the filtered vsst_ endpoints). Filter unit-pinned in test-job-model (owner-lineage / groupmate / stranger fixture trio).
  • User (cookie-auth) always has full view+control; every denial teaches.

8. CLI — full specification (vibespace-job, static tracked)

Conventions: <ref> = job id (jb-…) or scoped name; durations accept 30s/10m/6h; all verbs honor access scopes server-side; every error names the fix; no-args prints usage + the caller's visible jobs. From inside a job process the same CLI works (env carries VIBESPACE_API + job token + VIBESPACE_JOB_ID, so progress/ask/answers need no <ref>).

CREATE (one verb; flags pick the kind)
  vibespace-job run "CMD" --name NAME [common] [service|task|cron flags]
    common:
      --cwd DIR              default: caller session cwd
      --context "TEXT"       amnesia-proof brief, ≤8KB, echoed on poll (@file to read from file)
      --env K=V …            non-secret env (repeatable)
      (a secret: from the vault only — design-vault-service-secret; the `--secret` / envFrom road never shipped in the CLI and is retired, `envfrom_retired`, §7)
      --view session|group|all     default group
      --control session|group|all  default session
      --host MACHINE         M2; default local
    service (--keep-up):
      --keep-up              makes it a service
      --restart on-failure|always|never   default on-failure
      --health port:3000 | http://127.0.0.1:3000/healthz | cmd:"CMD"   optional
      --port N …             declared listeners (annotates port sweep)
      --publish              expose via forward/frp; URL shown in panel + `poll`
      --stop-with-owner      opt-in coupling to the owning conversation
    task (default kind):
      --until "TEXT"         literal marker in output ⇒ completion (grace 30s then TERM)
      --timeout 6h           default unbounded
      --stdin-open           keep stdin pipe open (interaction answers mirror to stdin)
      --no-single-instance   allow concurrent runs of this record (default: locked)
      --notify-user          file an inbox item on completion/failure
    cron (a schedule flag makes it a cron; CMD becomes the spawned task):
      --every 30m [--jitter 20%]   floor 15min for agent-created
      --cron "41 9 * * *"
      --at "2026-09-05 06:00"      one-shot; catchUp defaults once
      --notify-on fail|done|always|never   default fail
  vibespace-job notify-cron --name NAME (--every…|--cron…|--at…) --text "…" [--urgency low|normal|high]
                                       # cron whose action is notify-only (no process)

READ
  vibespace-job list [--kind service|task|cron] [--all] [--json]
                                       # one line per job: id state name uptime/next-fire + payload first line
  vibespace-job poll <ref> [--wait 100] [--tail 40] [--json]
                                       # state, exit+cause, timestamps, FULL context payload,
                                       # log tail; --wait long-polls (CLI cap 100s, prints
                                       # state on timeout/TERM — never silent)
  vibespace-job logs <ref> [--run N] [--tail 200]

CONTROL
  vibespace-job stop <ref> [--force]   # task: TERM→(grace)→KILL; service: desiredUp=false then stop
  vibespace-job start <ref>            # service/cron (also un-parks a failed service)
  vibespace-job rm <ref> [--stop]      # refuses on any non-terminal job; --stop kills-verified
                                       # first; --orphan (logged) is the only live-abandon path
  vibespace-job access <ref> [--view X] [--control X]   # refused if user-locked
  vibespace-job progress "TEXT"        # from inside the job (or: progress <ref> "TEXT")

INTERACTION (§6.4)
  vibespace-job ask --form @panel.json [--timeout 30m]   # from inside the job (or ask <ref>)
  vibespace-job answers [--wait 100] [--all]             # poll user replies (or answers <ref>)

Sample outputs (teaching-shaped):

$ vibespace-job run "python3 collect.py" --name collect-x --context "goal: 500 prompts; output: /data/x.jsonl; resume: rerun with --resume" --until "COLLECTED"
started jb-3f9a2c (task collect-x) — log data/job-logs/jb-3f9a2c/20260817-1102.log
this job OUTLIVES your conversation. Poll it (even from a future session):
  vibespace-job poll jb-3f9a2c
runtime note: server restarts survive; a container rebuild kills tasks (checkpoint your work).

$ vibespace-job poll jb-3f9a2c --tail 3
jb-3f9a2c collect-x [task] state=done exit=0 cause=ok(until-output) ran 2h42m (ended 13:44Z)
context: goal: 500 prompts; output: /data/x.jsonl; resume: rerun with --resume
log tail (3/48219 lines):
  [498/500] prompt saved
  [499/500] prompt saved
  COLLECTED 500 prompts → /data/x.jsonl
next: vibespace-job logs jb-3f9a2c --tail 200 · vibespace-job rm jb-3f9a2c

Injected teaching (ONE block, negative space included): "turn-scoped waits → background Bash/Monitor · session continuation → /goal · anything that must OUTLIVE this conversation → vibespace-job run "cmd" --name x --context "…" (--keep-up = service · --every/--at = schedule) · never harness cron (expires) · machine-fireable dates → --at, not a dated backlog item" — plus the session-start jobs digest (§6.3b) so resumed sessions rediscover their work (backlog-add with a leading ⏰/date pattern prints a pointer to --at).

9. UI / UX

Entry points: rail icon (stack/gears glyph) + ⚙ menu "Background Work" + window type jobs (openSpec openJobs, replayable); taskbar shows a severity-segmented badge (red failed · amber awaiting-user · green done-unacked) reusing the 2.339.0 segmented-badge pattern; every count clickable → panel filtered to that state.

Jobs window (two-line rows; live via jobs-updated, no polling; countdown humanized on a 30 s unref'd tick):

┌ Background Work ────────────────────────────── [+ New] [⚙] ┐
│ SERVICES · 2 up · 1 parked                                   │
│ ● dev-server      :3000 ↗ https://…frp…/        up 3h 12m    │
│   会话「重构」· 组 个人项目 · on-failure · ♥port    [Stop][Logs] │
│ ✖ mock-api        parked (6 crashes, exit 1)               │
│   backoff exhausted 11:02 · 点 Start 重试            [Start][Logs] │
│ TASKS · 1 running · 1 awaiting you · 1 done                  │
│ ◐ collect-x       2h42m · 「347/500 done」          [Stop][Logs] │
│   ctx: goal: 500 prompts; output: /data/x.jsonl…             │
│ ✋ qr-login        awaiting your input (12m)        [Open panel] │
│ ✔ export-pdf      done 08-16 · exit 0 · ok          [🗑][Logs]  │
│ CRON · 2 scheduled                                           │
│ ⏰ daily-patrol    41 9 * * * ±20% · next ~3h       [Pause][Runs]│
│ ⏰ sep5-check      at 09-05 06:00 · in 18d          [Pause][Runs]│
└──────────────────────────────────────────────────────────────┘

Detail view (row click): identity (id copy-chip, owner conversation + group chips, host); state timeline; run history ring — one row per run (start/end · duration · exit+cause · trigger · log link opening the existing file viewer); context payload block (monospace, copy button); access row (view/control dropdowns + 🔒 user-lock toggle; agent-attempted changes show as refused events); health/schedule editors (owner/user only); publish URL with copy; danger zone (rm rules from §8 surfaced as buttons with the same refusals). All strings escHtml — plain text everywhere (XSS law; M1 gate asserts it).

Interaction panel window (job-interact, small, centered, mobile = full width): title bar carries the job name + remaining timeout; blocks stack vertically; submit disabled until validation passes; after submit shows "已提交,任务继续运行" and auto-closes unless the job posts another panel. Arrival UX: toast + amber badge + inbox item; clicking any of them opens the panel window. Panel state is openSpec-replayable (restart-safe).

Toasts (existing anchored-card system): service crash/park, task done/failed (with job name + one-click open), panel arrival. Quiet-success crons show nothing (their notify-on fail default only surfaces failures).

Session Properties gains "Background work": jobs owned by this conversation; killing a session never blocks on jobs — informational dialog with per-job stop checkboxes, default keep-running (detachment is the thesis); --stop-with-owner jobs are pre-checked.

Escape visibility (M1): a collapsed "Outside the registry" section lists detected systemd --user units + crontab entries read-only, so hand-rolled escapes are at least visible where the registry lives; M2 import = register + disable source atomically.

10. Milestones & gates

  • M1 (local core): store + PURE model + engine (service/task/cron with spawn-task + notify) + adopt/replay + single-engine lock + full CLI (§8) + REST + jobs window + detail + until-output + polls + passive injection (§6.3b) + orphan-track + escape listing. Gates: test-job-model (schedule math, state machine, vendor-pattern refusal, panel-schema validation), test-jobs-engine (worktree: spawn → SIGKILL server → reboot → adopt-by-stamp; second-engine refusal; probe-env assert; until-output completion; GC-never-on-live), restore-smoke assertion (registered service survives restart), panel XSS assert, resume-owner-conversation poll test, arch-tier entries, kb + CHANGELOG same-commit.
  • M1.5: Interaction Panels tier 1 (§6.4) + --stdin-open + answers long-poll + job-interact window.
  • M2: remote hostId via agentd ops (capability-gated, three-touch, parity, remote-secrets subsection) · resource caps (prlimit; cgroup where available) · --publish deep integration · systemd/crontab import (register + disable source atomically).
  • M3: job templates (deploy monitor, health watchdog + remediation, patrol battery) · fleet/admin aggregate view · panel tier 2 (sandboxed iframe) if tier 1 proves insufficient.

11. Remaining open questions

  1. Retention defaults: runs ring 20 / logs 50 MB per job (rotated) / done-task records 14 d — tune?
  2. Interaction Panels in M1.5 as scoped here, or pull input+buttons-only minimal panels into M1 (the QR/code case is the live pain)?
  3. The jobs digest budget (600 B default) — enough, or make it a setting under agents.*?
  4. Channel research-preview drift — moot: the experimental VibeSpace channel was removed in 2.369.202 (B-df40, docs/settings.md "Removed 2026-10").

12. Owner auto-notify (2.344.0, owner-approved B-0bf4 — SHIPPED)

The poll-only stance of §6 gains a PUSH lane that keeps the automation red line intact: VibeSpace never fabricates user input into a session's stdin — delivery rides Claude Code's own cross-session messaging inbox (GA, CLI ≥2.1.224; official doc docs/en/cross-session-messaging). Facts the design rests on (binary forensics on 2.1.229 + live self-probe, 2026-08-17):

  • Every session binds a unix inbox socket, registered in ~/.claude/sessions/<pid>.json (messagingSocketPath, sessionId, name) with a published per-session auth key file (<pid>.<sha>.key, 0600) — the by-design same-OS-user peer auth path (sessions use it on each other).
  • Wire = newline JSON: {"type":"auth","token":<key>} then {"type":"user","message":{"role":"user","content":<text>}} (the CLI's own embedded injection recipe). Delivery: queued mid-turn, a new turn when idle, billed like a typed prompt, CLI-side throttles/dedup/50-cap.
  • Our sessions run bypassPermissions, whose inbound default HOLDS unattested senders → we pre-accept via the documented --settings {"crossSessionInbound":"accept"} at spawn (repo/managed settings can still tighten; a hold is never bypassed).

Mechanics. src/peer-messaging.js (registry scan + two-frame post; gated by scripts/test-peer-messaging.mjs against a real fake inbox). JobManager._notifyOwner fires at: task terminal (skipping cron quiet-success AND agent-initiated interrupted), service park, cron missed, panel posted (ask), panel answered. Toggle resolution notifyEffective: job --notify on|off > group tri-state (taskGroup.jobNotify, any explicit OFF wins) > global agents.jobNotify (default ON, owner call). Engine rate floor: ≥30 s per conversation + identical-text 10 min (under the CLI's own throttles). Message text (renderOwnerNotify, ≤1000 cp) carries job id/state, a context-payload head, the poll pointer, and an explicit "not a user instruction" marker.

Offline stash. No live inbox (session closed / remote / codex) → data/job-notifications.json, keyed by conversation lineage id, 30/convo cap, atomic writes on the engine's own dirty/flush cycle. Drained at the NEXT task-context (SessionStart/resume) AND prompt-context injection via renderNotifStash (≤900 B, endpoints survive, middle elided, floor line) — passive turn-boundary injection, never a fabricated turn.

Agent awareness. The create response (and vibespace-job run output) carries notify: {enabled, source, mode: live-message|resume-inject|off, reason} — the agent knows at creation whether it will hear back or must poll. User visibility. Session Properties gains a read-only Background Work section (effective state + deciding layer); the job detail shows the last notify outcome (lane + age + per-job override); toggles live in Settings → Integration (global) and the Task Group window (tri-state).

VibeSpace channel — REMOVED in 2.369.202 (B-df40). The experimental Claude Code channel (2.344.0–2.369.201: an MCP stdio server registered per spawn with the development-channels flag, preferred by the deliver ladder as rung 0 through a per-session socket) was superseded — external chat tools arrived as the Channels feature instead — and is gone with its setting, its socket and its script; the boot migration 2026-10-settings-rows-retired clears what it left. The ladder now starts at the local CLI inbox.

2.345.0–2.347.0 additions (owner-directed, all shipped same-day): context echo fixed for the production {payload} shape (live-E2E catch — the unit fixture shared the implementation's wrong assumption); per-conversation SUBSCRIPTIONS (subscribe <id> [--filter regex] — canView to join, cap 10, cron parent covers child runs, per-subscriber case-insensitive filter over the notification text, fail-closed, re-subscribe updates own filter, independent of the owner-lane toggles); announce "text" (jbt_ self-act or canControl — newsworthiness decoupled from exit codes; quiet-success demoted from law to DEFAULT via --notify-ok); truncated stash injections point at an untruncated spill file data/job-notifications-read/<cid>.md (append, 256KB head-trim, 14d GC); full self-inspection (show <id>, list --mine|--subscribed, snapshots carry all creation params, env values never leave the store); teaching kept in sync across ALL THREE renderers.

13. Triage: acknowledgement, folding, archival (2026-09-14, owner-approved; 5b 2026-09-15)

Measured problem (the owner's instance, 2026-09-14): 248 kind:'task' records — 207 done and ALL older than a day (nothing ever archived a one-shot), 36 failed (33 from two render sessions of one project, ages 1–7 days), the rail badge read "36!" for ever, and expanding a failed row showed nothing actionable. Owner-approved defaults: 24 h / 7 d / notified-counts-as-acknowledged. Every rule below is PURE where it is a decision (src/job-model.js, src/lib/jobs-layout.js) and ORCH where it touches disk (src/jobs.js, src/server/jobs-wiring.js).

13.1 Acknowledgement (PURE ackState(job, now) → {acked, by, at})

A terminal one-shot is a task (a cron's per-fire child included; never a service, never a cron parent — they are not one-shots) in state done | failed | interrupted | missed | unverified. It is acknowledged when:

  • (a) notified — job.notifyLog carries an ok:true delivery AT OR AFTER the terminal run's endedAt on a lane that reached a conversation or the user (message / channel / rpc-queue — codex's app-server lane, a billed turn/start when idle, a steer or queue/add when busy / user-inbox / remote-message; test-job-model CENSUSES the ladder's ok:true lanes against ACK_LANES, so a lane the ladder grows cannot strand a backend's owners — 2026-09-16). A stash, off or suppressed entry is NOT an acknowledgement — nobody has read it yet. A stash DRAINED later into a resume IS: the engine stamps job.ack = {by:'notified', at} at the drain, on every job the drained block named.
  • (b) agent-read — an agent of the job's OWNER LINEAGE (the CLI's own view/control predicate in job-model, never a second one) ran poll / show / logs on it after the terminal instant; the route stamps {by:'agent-read', at}. A jbt_ job-token read of ITSELF never acknowledges, nor does a stranger session's read.
  • (c) user-opened — the user expanded its row in the panel (POST /api/jobs/:id/seen stamps {by:'user-opened', at}).

ack is persisted on the record (writeJsonAtomic through _save) and broadcast like every other job change; a NEW run (start/restart) clears it. The client READS job.ack — the server computes it over the full record (the snapshot's journal is truncated) — and never derives acknowledgement.

13.2 The badge and the summary (PURE badgeCounts in src/lib/jobs-layout.js)

attention = awaiting-user + unacknowledged (failed | missed | unverified) is the red number on the rail badge and in both panel summaries; acknowledged failures are a grey "N seen", never red. ONE PURE counter — the panel used to compute bad twice. The agent digest (digestFor / updatesFor) is unchanged except that archived records are excluded.

13.3 Folding (PURE foldTasks(tasks, {expanded, sessionNames}))

The Tasks section groups one-shots by OWNER SESSION (label = the sidebar's display name when known, else the short id; a task with no owner session is its own "created by you" group), then by NAME FAMILY inside a session (familyOf: a trailing run, -r<n>, -v<n>, -<n> and version-like tails are stripped until the name is stable — a pure string rule with a table test). A group row is a <button aria-expanded> (keyboard-reachable) showing label · count · running · failed (unacked red / acked grey) · latest age. A group is expanded by DEFAULT when it holds a running job, an awaiting-user job or an unacknowledged failure, else collapsed; the user's choices persist in user state jobsPanelFolds (PATCH merge-only, pruned to live groups by pruneFolds) and are applied AFTER the defaults. Inside an expanded group the rows keep the existing renderer; Services and Cron keep their flat rendering.

13.4 A failed row says why

The one-shot row for failed | missed | interrupted shows the exit code and the LAST NON-EMPTY LINE of its log (run.lastLine, computed ONCE at finalize from the log file, ≤ 200 code points — the job's own words as written: the literal-redaction that re-read a plaintext store is retired with it, §7), so the expanded content is actionable. The detail view is unchanged.

13.5 Archival (ORCH src/jobs.js)

A sweep at boot and every 5 min ON THE ENGINE'S EXISTING TICK (no second timer) moves terminal one-shots out of data/jobs.json into data/jobs-archive.json (append-only, newest 2000; a record keeps runs / notifyLog / ack and gains archivedAt + archivedWhy ∈ done | failed-acknowledged | missed-acknowledged | interrupted-acknowledged | unverified-acknowledged):

  • done → after jobs.archiveDoneAfterHours (default 24) since endedAt;
  • failed | missed | interrupted | unverified → after jobs.archiveFailedAfterDays (default 7) since the ACKNOWLEDGEMENT, and never while unacknowledged;
  • never a service, never a cron parent, never a job with a live pid or an open interaction, never a cron child whose schedule is still active;
  • 0 = never archive. The per-job logs dir follows the existing retention.

Both numbers are settings rows in the Background Work category (SETTINGS_CATEGORIES, zh+ja). GET /api/jobs?archived=1 lists the archive (same snapshot shape + archived:true, archivedAt, archivedWhy); vibespace-job list --archived prints it; poll / show / logs of an ARCHIVED id read through to the archive (response carries archived:true) — an agent holding an old id never gets a 404 because of housekeeping. The panel shows an "Archived · N" row at the foot of Tasks that fetches the archive ONLY when clicked; ✕ on an archived row deletes it for good (record + log dir) behind the existing confirm dialog. One jobs-updated {archived:[ids]} broadcast per sweep, never per record. The archive copy is DURABLE before a record leaves jobs.json (2026-09-16): the sweep writes the archive FIRST and releases the records only when that write succeeded — a failed write (ENOSPC / EACCES / EROFS) releases nothing, logs the verbatim error + telemetry jobs-archive-write-failed, and the next sweep retries; a record re-archived after a failed store flush REPLACES its older copy, never duplicates it; rm on an archived id keeps the row when the rewrite fails.

13.5b Held notifications are visible, and the ledger says who spent (2026-09-15)

After a real incident (the P4 spend guard's per-slot hourly cap — spend.unattendedPerIdentityHour, shared by EVERY conversation on that credential slot — was reached, the jobs lane STASHED each notification and the owner saw nothing until the next prompt drained them, so the product read as a broken notifier): ① every stash entry is TYPED (held.kind ∈ spend-cap | rate-floor | not-reachable | off, with the slot + ceiling the ladder's spend refusal names; an older entry with no held reads as not-reachable), the held digest {total, byConversation:{cid:{count, kinds, reason}}} rides GET /api/jobs and EVERY jobs-updated broadcast, and the panel summary, the rail badge tooltip and the affected conversation's chat status-bar chip say why in the device's own words (heldText; the server sends structure, the client says the words; the chip clears on drain); ② src/server/spend-guard.js's ledger stamps carry the REASON (the SPEND_REASONS member) beside the timestamp — {at, reason}; a bare number from an older build loads and counts as producer unknown; ③ the guard's 80 % notice and its refusal inbox item name the window's top producers. Defaults are NOT changed here (owner decision pending on 30/h · 200/day · 800/instance/day).

13.6 Gates

scripts/test-job-model.mjs (ackState truth table incl. stash-vs-drained and the self-read exclusion; foldTasks table, family rule, default expansion, persisted-fold override; the badge helper), scripts/test-jobs- triage.mjs (fast: the real engine + real user routes over the NEUTRAL fixture scripts/jobs-triage-fixture.mjs — 200 done > 1 d, 36 failed with mixed acknowledgement — with an injected clock: who archives / who stays / an unacked failure never at any age / read-through / the cap / a restart / one broadcast per sweep / /seen / ?archived=1 / the CLI's --archived), scripts/test-jobs-panel.mjs (heavy chrome at 1200×800 and 375×667: the badge, default expansion, a persisted collapse across a reload, the failed row's last line, the held summary, the Archived row fetching only on click), scripts/test-spend-paths.mjs (the ledger stamp shape + prune compatibility).

Invariants. A notification nobody could have read is not an acknowledgement. A job never acknowledges itself. An unacknowledged failure is never archived, at any age, on any path. Housekeeping never answers an agent's id with a 404. A fold that hides a running or awaiting-user row by default is a defect. A persisted fold outliving its group grows user state without bound, so folds are pruned to live groups. A sweep broadcasts once. A stash entry without a typed reason is a notifier that reads as broken.