Repository navigation
Description
[Frontier] [WORK15-02] Keep owner stall replies that wait to resume a duty durable across restart
Parent
S15 — #316. Canonical spec: docs/specs/S15-conversational-autonomy.md.
Model tier
Frontier: restart reconciliation of a pending authority-bearing resume (the owner's reply must still be the authorization, exactly once, after the pause, in scope).
Gap found (review follow-up from PR #363, verified on main c5f476e)
src/s15/web/handler.ts keeps owner stall replies that wait for their own CHAT completion before work.resume in process memory (const resumes = new Map<string, { dutyId; conversationId }>() and resumeRefusals). settleResumes runs only when the owner renders a page. If the controller restarts after the owner sent "try the recommendation" but before that request completes, the binding is lost and the paused responsibility never resumes, even though the owner asked for it. Refusal reasons are lost the same way. Telegram (CHAN15-01) needs the same binding outside the Web handler.
Context to read
AGENTS.md; CONTEXT.md; docs/specs/S15-conversational-autonomy.md (S15-D22, S15-AC15); src/s15/web/handler.ts (resumes, resumeRefusals, settleResumes, /s15/forms/message duty handling); src/s15/web/ports.ts (s15DutiesPort); src/s15/work/** (resume, responsibility projection, pause checks); src/s15/conversation/** (request rows and states); src/s15/composition.ts (drain).
Deliverable
A protected, durable "resume after this owner request completes" binding owned by the WORK/conversation domain, used by the Web handler now and by Telegram later. It settles exactly once after the named CHAT request reaches a terminal state, including after a restart and without needing a page view, and keeps refusal reasons visible.
Implementation steps
- Add a versioned table in the protected S15 journal for pending resume bindings: request ID (unique), duty ID, conversation ID, owner identity/generation snapshot, created time, state (
pending/resumed/refused), refusal code. - Record the binding in the same protected step that accepts the owner's stall reply (no window where the request exists without its binding).
- Settle in the service drain loop: for each pending binding whose request is terminal, call the existing WORK resume checks (arrived after the pause, in scope, authorized). Decide and document how the authorization is carried without a live Web session: the authenticated owner request that created the binding is the authorization; a stale owner generation or revoked scope refuses.
- Remove the in-memory maps from the Web handler; render refusal reasons from the durable table.
- Expose a small port (e.g.
bindResume(actor, {requestId, dutyId, conversationId})and a projection) that CHAN15-01 reuses. Bounded retention for settled rows.
Acceptance criteria
- A stall reply sent before a restart resumes the duty after restart once its request completes, without any page view.
- Resume happens at most once per binding; a second settlement pass is a no-op.
- A request that ends not
done, a stale owner generation or a revoked scope records a refusal that the Web UI shows. - The Web handler holds no resume state in memory.
Tests and evidence
- Bind → restart service → request completes → drain resumes the duty once.
- Request ends
refused/failed→ bindingrefusedwithexecution_not_authorized; UI shows it. - Owner generation changed before settlement → refused.
- Two drains concurrently → one resume.
- Existing web/work suites pass unchanged.
Requirements
FR-04/14/15/18/26/28; NFR-05/08/10. Acceptance: S15-AC15.
Blocked by
None. Bundled in one PR with CHAN15-01 (#368).
Contracts used/delivered
Uses WORK resume and conversation request rows. Delivers a durable resume binding port with TSDoc.
Out of scope
Changing pause/stall detection, budgets or recommendations.
Do not
Do not resume from model output or quoted text; do not resume twice; do not add AI attribution; do not merge the PR yourself.
Definition of done
bun install --frozen-lockfile, bun run typecheck, bun run test, git diff --check pass on the final HEAD. PR body includes Closes #367. No merge by the implementer.
Activity