From f82612ecdc6609749ae0ee51bb32d9febd10f70e Mon Sep 17 00:00:00 2001 From: karim Date: Sat, 17 Oct 2026 15:37:42 +0200 Subject: [PATCH 1/3] A kept session is owned by the conversation that started the run (F121) - The ownership key of a HarnessSession is the id of the conversation that started the workflow run where there is one, carried onto the step's context by the orchestrator so the executor reads it as data and never infers it; a run started from no conversation keeps the run id as owner, so a workflow's own tasks still share a session - ownedSession and the retry path by logicalExecutionId read that key; the refusal states the conversation rule and keeps the sentence listing how a kept session ends, including the failed re-preparation of the checkout - Tests: a second run by the same conversation continues the session; a run from another conversation is refused with the same text a missing session gets; a run with no conversation falls back to run ownership; the existing retry-continues test still holds - The Commission Change skill text and the plan's F121 row match what is built --- .../src/__tests__/harnessContinueFrom.test.ts | 99 ++++-- .../harnessConversationOwnership.test.ts | 302 ++++++++++++++++++ .../src/__tests__/harnessSessions.test.ts | 64 ++-- .../__tests__/withdrawalAndLifetime.test.ts | 3 +- .../src/handlers/harnessHandlers.ts | 35 +- .../src/harnessSessions.ts | 42 ++- .../scheduling/workflowTimerJob.ts | 3 + .../__tests__/operationTaskDispatch.test.ts | 73 ++++- .../cybernetic/harness/dispatchRetriedTask.ts | 3 + .../cybernetic/harness/dispatchTask.ts | 3 + .../harness/operationTaskDispatch.ts | 22 ++ .../services/cybernetic/harness/pollPolicy.ts | 4 +- docs/plans/315-local-first-run-ergonomics.md | 2 +- .../__tests__/startingConversation.test.ts | 57 ++++ .../src/executor/buildContext.ts | 3 + packages/executor-runtime/src/types.ts | 7 + .../src/skillCatalog/commissionChange.ts | 6 +- .../src/storeCatalogHashes.json | 2 +- packages/schemas/src/operations/host.ts | 6 +- .../schemas/src/runtime/streamMessages.ts | 12 + 20 files changed, 652 insertions(+), 96 deletions(-) create mode 100644 apps/aflow-executor-host/src/__tests__/harnessConversationOwnership.test.ts create mode 100644 packages/executor-runtime/src/__tests__/startingConversation.test.ts diff --git a/apps/aflow-executor-host/src/__tests__/harnessContinueFrom.test.ts b/apps/aflow-executor-host/src/__tests__/harnessContinueFrom.test.ts index 43115714..c3fea93b 100644 --- a/apps/aflow-executor-host/src/__tests__/harnessContinueFrom.test.ts +++ b/apps/aflow-executor-host/src/__tests__/harnessContinueFrom.test.ts @@ -1,15 +1,16 @@ /** * Contract: a commission naming a session it cannot continue is told the rule - * that decides which sessions it can — a kept session is owned by the run that - * made it, and every commission is a new run — and nothing about the session it - * named. It also says the ways a kept session ends — once it has been idle for - * an hour, once the executor has restarted since it was made, once a turn - * naming a new base has failed to prepare its checkout again, or once the grant - * of its connected folder is withdrawn — because a session this run made and - * lost one of those ways reaches the same refusal as another run's. Another - * run's session, this run's idled-out one and one that never existed all get - * one answer, so the refusal confirms nothing and guesses at no reason for the - * ref it names. + * that decides which sessions it can — a kept session is owned by the + * conversation that started the run that made it, and by the run itself where + * no conversation started one — and nothing about the session it named. It + * also says the ways a kept session ends — once it has been idle for an hour, + * once the executor has restarted since it was made, once a turn naming a new + * base has failed to prepare its checkout again, or once the grant of its + * connected folder is withdrawn — because a session its own conversation made + * and lost one of those ways reaches the same refusal as another + * conversation's. Another conversation's session, its own idled-out one and + * one that never existed all get one answer, so the refusal confirms nothing + * and guesses at no reason for the ref it names. */ import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; @@ -30,8 +31,10 @@ const { createHostHarnessHandler } = await import('../handlers/harnessHandlers.j const { forgetSession, recordSession, SESSION_IDLE_TTL_MS } = await import('../harnessSessions.js'); const THIS_RUN = 'run-this-commission'; -const OTHER_RUNS = 'hs_other_run'; -const IDLED_OUT = 'hs_this_run_idled_out'; +const THIS_CONVERSATION = 'conversation-this'; +const OTHER_CONVERSATIONS = 'hs_other_conversation'; +const IDLED_OUT = 'hs_this_conversation_idled_out'; +const OTHER_RUNS = 'hs_other_run_no_conversation'; let base: string; let policyPath: string; @@ -63,16 +66,20 @@ beforeEach(async () => { ], }), ); - // Made by another commission, on the same folder and harness, and live. - keep(OTHER_RUNS, 'run-earlier-commission', Date.now()); - // Made by this commission, and idle past the hour since. - keep(IDLED_OUT, THIS_RUN, Date.now() - SESSION_IDLE_TTL_MS - 1); + // Made by a commission another conversation started, on the same folder and + // harness, and live. + keep(OTHER_CONVERSATIONS, 'conversation-other', 'run-other-conversation', Date.now()); + // Made by an earlier commission of this conversation, and idle past the hour since. + keep(IDLED_OUT, THIS_CONVERSATION, 'run-earlier', Date.now() - SESSION_IDLE_TTL_MS - 1); + // Made by a run no conversation started, and live. + keep(OTHER_RUNS, 'run-unstarted', 'run-unstarted', Date.now()); }); -function keep(id: string, ownerRunId: string, lastUsedAt: number): void { +function keep(id: string, ownerId: string, runId: string, lastUsedAt: number): void { recordSession({ id, - ownerRunId, + ownerId, + runId, logicalExecutionId: 'task:commission', bindingId: 'hb', harnessId: 'converses', @@ -88,17 +95,22 @@ function keep(id: string, ownerRunId: string, lastUsedAt: number): void { } afterEach(async () => { - forgetSession(OTHER_RUNS); + forgetSession(OTHER_CONVERSATIONS); forgetSession(IDLED_OUT); + forgetSession(OTHER_RUNS); await rm(base, { recursive: true, force: true }); }); -async function commission(continueFrom: string): Promise<{ status: string; message: string }> { +async function commission( + continueFrom: string, + startingConversationId: string | undefined = THIS_CONVERSATION, +): Promise<{ status: string; message: string }> { const written: Record = {}; const result = await createHostHarnessHandler(policyPath).execute({ operationId: 'host.harness.run', spaceId: 'space-test', runId: THIS_RUN, + ...(startingConversationId !== undefined ? { startingConversationId } : {}), job: { inputRef: 'inline:x' }, signal: new AbortController().signal, log: { error: () => undefined, warn: () => undefined, info: () => undefined }, @@ -122,48 +134,56 @@ async function commission(continueFrom: string): Promise<{ status: string; messa describe('a commission that names a session it cannot continue', () => { it('is told the ownership rule', async () => { - const { status, message } = await commission(OTHER_RUNS); + const { status, message } = await commission(OTHER_CONVERSATIONS); expect(status).toBe('FAILED'); - expect(message).toContain('A kept session is owned by the run that made it'); - expect(message).toContain('every commission is a new run'); - expect(message).toContain('`continueFrom` can name only a session this same run made'); - expect(message).toContain('a session made by any other run is refused'); + expect(message).toContain( + 'A kept session is owned by the conversation that started the run that made it', + ); + expect(message).toContain( + '`continueFrom` can name a session made by any run that same conversation started', + ); + expect(message).toContain('a run no conversation started owns the sessions it makes itself'); + expect(message).toContain('A session owned by any other conversation or run is refused'); + expect(message).not.toContain('every commission is a new run'); }); it('is told that a kept session ends after an idle hour', async () => { - const { message } = await commission(OTHER_RUNS); + const { message } = await commission(OTHER_CONVERSATIONS); expect(SESSION_IDLE_TTL_MS).toBe(60 * 60 * 1000); expect(message).toContain('A kept session ends once it has been idle for an hour'); }); it('is told that a kept session ends when the executor restarts', async () => { - const { message } = await commission(OTHER_RUNS); + const { message } = await commission(OTHER_CONVERSATIONS); expect(message).toContain('once this executor has restarted since it was made'); }); it('is told that a kept session ends when its checkout cannot be prepared again', async () => { - const { message } = await commission(OTHER_RUNS); + const { message } = await commission(OTHER_CONVERSATIONS); expect(message).toContain( "once a turn naming a new `base` has failed to prepare the session's checkout again", ); }); it("is told that a kept session ends when its folder's grant is withdrawn", async () => { - const { message } = await commission(OTHER_RUNS); + const { message } = await commission(OTHER_CONVERSATIONS); expect(message).toContain('or once the grant of its connected folder is withdrawn.'); }); it('makes no guess about why the ref it named in particular is not held', async () => { - const { message } = await commission(OTHER_RUNS); + const { message } = await commission(OTHER_CONVERSATIONS); expect(message).not.toMatch(/\b(may|might|probably|perhaps|likely)\b/i); - expect(message.startsWith(`No session \`${OTHER_RUNS}\` is held for this run. `)).toBe(true); - expect(message.split(OTHER_RUNS)).toHaveLength(2); - expect(message).not.toContain('run-earlier-commission'); + expect(message.startsWith(`No session \`${OTHER_CONVERSATIONS}\` is held for this run. `)).toBe( + true, + ); + expect(message.split(OTHER_CONVERSATIONS)).toHaveLength(2); + expect(message).not.toContain('conversation-other'); + expect(message).not.toContain('run-other-conversation'); }); - it("gets one answer for another run's session, this run's idled-out one and one never made", async () => { + it("gets one answer for another conversation's session, its own idled-out one, a conversation-less run's and one never made", async () => { const answers = await Promise.all( - [OTHER_RUNS, IDLED_OUT, 'hs_never_made'].map(async (ref) => { + [OTHER_CONVERSATIONS, IDLED_OUT, OTHER_RUNS, 'hs_never_made'].map(async (ref) => { const { status, message } = await commission(ref); expect(status, ref).toBe('FAILED'); return message.replace(ref, ''); @@ -171,4 +191,13 @@ describe('a commission that names a session it cannot continue', () => { ); expect(new Set(answers).size).toBe(1); }); + + it('gets the same answer from a run no conversation started', async () => { + const fromConversation = await commission('hs_never_made'); + const fromNone = await commission(OTHER_CONVERSATIONS, undefined); + expect(fromNone.status).toBe('FAILED'); + expect(fromNone.message.replace(OTHER_CONVERSATIONS, 'hs_never_made')).toBe( + fromConversation.message, + ); + }); }); diff --git a/apps/aflow-executor-host/src/__tests__/harnessConversationOwnership.test.ts b/apps/aflow-executor-host/src/__tests__/harnessConversationOwnership.test.ts new file mode 100644 index 00000000..7dbbae6b --- /dev/null +++ b/apps/aflow-executor-host/src/__tests__/harnessConversationOwnership.test.ts @@ -0,0 +1,302 @@ +/** + * Contract (Plan 315 F121): a kept session is owned by the conversation that + * started the run that made it. A second run that conversation starts + * continues the coding agent's own conversation in the checkout the first one + * left; a run another conversation started is refused with the text a session + * that never existed gets; a run no conversation started owns its sessions + * itself, so its own tasks share one and no other run does. A retry continues + * only the session its own run made, though every commission a conversation + * starts is the same task. Exercised against real git; only the sandbox's + * spawn is stood in for, and the harness command runs. + */ +import { execFile } from 'node:child_process'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { promisify } from 'node:util'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { SandboxedRunInput, SandboxedRunResult } from '../sandboxedRun.js'; + +const run = promisify(execFile); +const handed: SandboxedRunInput[] = []; +let interruptNext = false; + +vi.mock('../sandboxedRun.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + sandboxReadiness: () => ({ ready: true, missing: [] }), + runSandboxed: async (input: SandboxedRunInput): Promise => { + handed.push(input); + const [program, ...args] = input.argv; + const { stdout, stderr } = await run(program ?? '', args, { + cwd: input.cwd, + env: { PATH: process.env['PATH'] ?? '', ...input.env, ...input.trustedEnv }, + }); + if (interruptNext) { + interruptNext = false; + throw new Error('the turn was ended before it answered'); + } + return { + processId: 'hr_test', + exitCode: 0, + signal: null, + timedOut: false, + durationMs: 0, + stdout, + stderr, + truncated: false, + }; + }, + }; +}); + +const { createHostHarnessHandler } = await import('../handlers/harnessHandlers.js'); +const { allSessions, discardScratch, forgetSession } = await import('../harnessSessions.js'); +const { removeWorktree } = await import('../worktree.js'); + +// Finishes only where an earlier turn already did the first half, so a turn +// that started over in a fresh checkout would do the first half again. +const WORKS_IN_HALVES = + "if [ -f half.txt ]; then printf 'whole\\n' > whole.txt; else printf 'half\\n' > half.txt; fi"; + +const CONVERSATION = '00000000-0000-4000-8000-0000000000c1'; +const OTHER_CONVERSATION = '00000000-0000-4000-8000-0000000000c2'; + +let base: string; +let project: string; +let policyPath: string; + +async function git(...args: string[]): Promise { + await run('git', [`--git-dir=${join(project, '.git')}`, `--work-tree=${project}`, ...args]); +} + +beforeEach(async () => { + handed.length = 0; + interruptNext = false; + base = await mkdtemp(join(tmpdir(), 'aflow-harness-owner-')); + project = join(base, 'project'); + policyPath = join(base, 'host-policy.json'); + await mkdir(project); + await git('init', '-q', '-b', 'main'); + await git('config', 'user.email', 't@e.com'); + await git('config', 'user.name', 'T'); + await writeFile(join(project, 'a.txt'), 'a\n'); + await git('add', '-A'); + await git('commit', '-q', '-m', 'init'); + await writeFile( + policyPath, + JSON.stringify({ + version: 1, + bindings: [ + { + id: 'hb', + root: project, + mode: 'readwrite', + allowsExecution: true, + singleFile: false, + spaceId: 'space-test', + }, + ], + harnesses: [ + { + id: 'converses', + executable: '/bin/sh', + args: ['-c', WORKS_IN_HALVES], + sessionArgs: ['--session', '{session}'], + resumeArgs: ['--resume', '{session}'], + }, + ], + }), + ); +}); + +afterEach(async () => { + for (const session of allSessions()) { + forgetSession(session.id); + await removeWorktree(session.worktreePath).catch(() => {}); + await discardScratch(session); + } + await rm(base, { recursive: true, force: true }); +}); + +interface Commission { + readonly runId: string; + readonly conversation?: string; + readonly continueFrom?: string; + readonly attempt?: number; + readonly logicalExecutionId?: string; +} + +interface Outcome { + readonly status: string; + readonly output: Record; + readonly message: string; +} + +async function commission({ + runId, + conversation, + continueFrom, + attempt = 1, + logicalExecutionId = 'task:commission', +}: Commission): Promise { + const written: Record = {}; + const result = await createHostHarnessHandler(policyPath).execute({ + operationId: 'host.harness.run', + spaceId: 'space-test', + runId, + ...(conversation !== undefined ? { startingConversationId: conversation } : {}), + attempt, + logicalExecutionId, + job: { inputRef: 'inline:x' }, + signal: new AbortController().signal, + log: { error: () => undefined, warn: () => undefined, info: () => undefined }, + readPayload: () => + Promise.resolve({ + bindingId: 'hb', + harness: 'converses', + task: 'Do it in halves.', + timeoutMs: 60_000, + ...(continueFrom !== undefined ? { continueFrom } : {}), + }), + emitLiveDelta: () => Promise.resolve(), + writePayload: (kind: string, data: unknown) => { + written[kind] = data; + return Promise.resolve(`inline:${kind}`); + }, + } as never); + const error = written['error'] as { message?: string } | undefined; + return { + status: result.status, + output: (written['output'] ?? {}) as Record, + message: error?.message ?? '', + }; +} + +const changedFiles = (patch: unknown): string[] => + [...String(patch).matchAll(/^\+\+\+ b\/(.+)$/gm)].map((match) => match[1] ?? ''); + +/** The conversation a turn named, and whether it resumed it. */ +function conversationOf(input: SandboxedRunInput | undefined): { flag: string; id: string } { + const argv = input?.argv ?? []; + const at = argv.findIndex((arg) => arg === '--session' || arg === '--resume'); + return { flag: argv[at] ?? '', id: argv[at + 1] ?? '' }; +} + +describe('a session kept for a conversation', () => { + it('is continued by a second run the same conversation started', async () => { + const first = await commission({ runId: 'run-first', conversation: CONVERSATION }); + expect(first.status).toBe('SUCCEEDED'); + const sessionRef = String(first.output['sessionRef']); + + const fix = await commission({ + runId: 'run-fix', + conversation: CONVERSATION, + continueFrom: sessionRef, + }); + + expect(fix.status).toBe('SUCCEEDED'); + expect(fix.output['continued']).toBe(true); + expect(fix.output['sessionRef']).toBe(sessionRef); + const [made, continued] = handed; + expect(conversationOf(made).flag).toBe('--session'); + expect(conversationOf(continued)).toEqual({ flag: '--resume', id: conversationOf(made).id }); + expect(continued?.cwd).toBe(made?.cwd); + expect(changedFiles(fix.output['patch']).sort()).toEqual(['half.txt', 'whole.txt']); + }, 60_000); + + it('is refused to a run another conversation started, as a session never made is', async () => { + const first = await commission({ runId: 'run-first', conversation: CONVERSATION }); + const sessionRef = String(first.output['sessionRef']); + + const elsewhere = await commission({ + runId: 'run-elsewhere', + conversation: OTHER_CONVERSATION, + continueFrom: sessionRef, + }); + const neverMade = await commission({ + runId: 'run-elsewhere', + conversation: OTHER_CONVERSATION, + continueFrom: 'hs_never_made', + }); + + expect(elsewhere.status).toBe('FAILED'); + expect(elsewhere.message.replace(sessionRef, '')).toBe( + neverMade.message.replace('hs_never_made', ''), + ); + expect(handed).toHaveLength(1); + // Refused, not taken: the conversation that owns it still continues it. + const fix = await commission({ + runId: 'run-fix', + conversation: CONVERSATION, + continueFrom: sessionRef, + }); + expect(fix.output['continued']).toBe(true); + }, 60_000); +}); + +describe('a session kept for a run no conversation started', () => { + it("is continued by the run's own later task and refused to any other run", async () => { + const first = await commission({ runId: 'run-unstarted' }); + const sessionRef = String(first.output['sessionRef']); + + const otherRun = await commission({ runId: 'run-another', continueFrom: sessionRef }); + const ownTask = await commission({ + runId: 'run-unstarted', + continueFrom: sessionRef, + logicalExecutionId: 'task:fix', + }); + + expect(otherRun.status).toBe('FAILED'); + expect(otherRun.message).toContain(`No session \`${sessionRef}\` is held for this run.`); + expect(ownTask.status).toBe('SUCCEEDED'); + expect(ownTask.output['continued']).toBe(true); + expect(changedFiles(ownTask.output['patch']).sort()).toEqual(['half.txt', 'whole.txt']); + }, 60_000); + + it('is refused to a run a conversation started', async () => { + const first = await commission({ runId: 'run-unstarted' }); + const sessionRef = String(first.output['sessionRef']); + + const fromConversation = await commission({ + runId: 'run-conversation', + conversation: CONVERSATION, + continueFrom: sessionRef, + }); + + expect(fromConversation.status).toBe('FAILED'); + expect(handed).toHaveLength(1); + }, 60_000); +}); + +describe('a retry in a conversation that started several runs', () => { + it("continues its own run's session, never an earlier run's of the same task", async () => { + await commission({ runId: 'run-first', conversation: CONVERSATION }); + + interruptNext = true; + const interrupted = await commission({ runId: 'run-second', conversation: CONVERSATION }); + expect(interrupted.status).toBe('FAILED'); + const retried = await commission({ + runId: 'run-second', + conversation: CONVERSATION, + attempt: 2, + }); + const coldRetry = await commission({ + runId: 'run-third', + conversation: CONVERSATION, + attempt: 2, + }); + + const [first, second, retry, cold] = handed; + expect(retried.output['continued']).toBe(true); + expect(retry?.cwd).toBe(second?.cwd); + expect(conversationOf(retry)).toEqual({ flag: '--resume', id: conversationOf(second).id }); + expect(coldRetry.output['continued']).toBe(false); + expect(conversationOf(cold).flag).toBe('--session'); + expect([first?.cwd, second?.cwd]).not.toContain(cold?.cwd); + expect(changedFiles(coldRetry.output['patch'])).toEqual(['half.txt']); + }, 60_000); +}); diff --git a/apps/aflow-executor-host/src/__tests__/harnessSessions.test.ts b/apps/aflow-executor-host/src/__tests__/harnessSessions.test.ts index 2332d003..005b6640 100644 --- a/apps/aflow-executor-host/src/__tests__/harnessSessions.test.ts +++ b/apps/aflow-executor-host/src/__tests__/harnessSessions.test.ts @@ -1,5 +1,5 @@ /** - * Contract: a conversation belongs to one run, and nothing it holds outlives + * Contract: a kept session belongs to one owner, and nothing it holds outlives * the grant that permitted it. */ import { describe, expect, it } from 'vitest'; @@ -12,6 +12,7 @@ import { newConversationId, ownedSession, recordSession, + sessionForRetry, sessionsForBinding, SESSION_IDLE_TTL_MS, touchSession, @@ -29,7 +30,8 @@ function makeSession(overrides: Partial = {}): HarnessSession { const id = overrides.id ?? nextSessionRef(); return { id, - ownerRunId: 'run-a', + ownerId: 'conversation-a', + runId: 'run-a', logicalExecutionId: `step:${id}`, bindingId: 'hb', harnessId: 'claude', @@ -44,35 +46,57 @@ function makeSession(overrides: Partial = {}): HarnessSession { }; } -describe('a conversation answers to one run', () => { - it('resolves for the run that started it', () => { +describe('a kept session answers to one owner', () => { + it('resolves for its owner', () => { const session = makeSession(); recordSession(session); - expect(ownedSession(session.id, 'run-a')?.id).toBe(session.id); + expect(ownedSession(session.id, 'conversation-a')?.id).toBe(session.id); forgetSession(session.id); }); - it('is absent to another run, not refused', () => { + it('is absent to any other owner, not refused', () => { // Saying "that belongs to someone else" confirms the ref exists, and a ref // is the only thing that has to be guessed. const session = makeSession(); recordSession(session); - expect(ownedSession(session.id, 'run-b')).toBeUndefined(); + expect(ownedSession(session.id, 'conversation-b')).toBeUndefined(); forgetSession(session.id); }); it('is absent when it was never there', () => { - expect(ownedSession('hs_nothing', 'run-a')).toBeUndefined(); + expect(ownedSession('hs_nothing', 'conversation-a')).toBeUndefined(); }); - it('is keyed on the run, so a second attempt at a step keeps its own session', () => { - // The executor derives `runId` from the job's session or workflow run, and - // `stepExecutionId` names an attempt. Keying on the attempt would disown a - // session on retry; keying on the run is what makes a conversation last. - const session = makeSession({ ownerRunId: 'run-a' }); + it('is not owned by the run that made it when a conversation owns it', () => { + const session = makeSession(); + recordSession(session); + expect(ownedSession(session.id, 'run-a')).toBeUndefined(); + forgetSession(session.id); + }); +}); + +describe('a retry finds the session of its own work in its own run', () => { + it('finds the session an earlier attempt at the same work in the same run made', () => { + const session = makeSession({ logicalExecutionId: 'task:commission' }); + recordSession(session); + expect(sessionForRetry('conversation-a', 'run-a', 'task:commission')?.id).toBe(session.id); + forgetSession(session.id); + }); + + it("does not find another run's session though the conversation and the task are the same", () => { + // A workflow names its work by task id, so every commission a conversation + // starts is `task:commission`; a retry of one must start in its own checkout. + const session = makeSession({ logicalExecutionId: 'task:commission' }); + recordSession(session); + expect(sessionForRetry('conversation-a', 'run-b', 'task:commission')).toBeUndefined(); + forgetSession(session.id); + }); + + it("does not find another owner's or other work's session", () => { + const session = makeSession({ logicalExecutionId: 'task:commission' }); recordSession(session); - expect(ownedSession(session.id, 'run-a')).toBeDefined(); - expect(ownedSession(session.id, 'step-1')).toBeUndefined(); + expect(sessionForRetry('conversation-b', 'run-a', 'task:commission')).toBeUndefined(); + expect(sessionForRetry('conversation-a', 'run-a', 'task:another')).toBeUndefined(); forgetSession(session.id); }); }); @@ -89,8 +113,8 @@ describe('a conversation does not outlive its usefulness', () => { expect(expired.map((s) => s.id)).not.toContain(fresh.id); // Gone from the registry, so a later continue finds nothing rather than a // reference to a checkout that has been cleaned up underneath it. - expect(ownedSession(stale.id, 'run-a')).toBeUndefined(); - expect(ownedSession(fresh.id, 'run-a')).toBeDefined(); + expect(ownedSession(stale.id, 'conversation-a')).toBeUndefined(); + expect(ownedSession(fresh.id, 'conversation-a')).toBeDefined(); forgetSession(fresh.id); }); @@ -173,16 +197,16 @@ describe('how a harness is told to continue is the harness own business', () => }); }); -describe('a session belongs to a folder as well as to a run', () => { +describe('a session belongs to a folder as well as to an owner', () => { it('is not resumable while naming a different binding', () => { // A run holding two bindings could otherwise resume one session while // naming the other: the sandbox would combine this binding's root with that // session's worktree, and a permission change on the session's own binding // would not apply. Process handles are scoped both ways; a session is a // longer-lived handle and needs it more. - const session = makeSession({ bindingId: 'hb_a', ownerRunId: 'run-a' }); + const session = makeSession({ bindingId: 'hb_a' }); recordSession(session); - const resolved = ownedSession(session.id, 'run-a'); + const resolved = ownedSession(session.id, 'conversation-a'); expect(resolved).toBeDefined(); expect(resolved?.bindingId).toBe('hb_a'); // The handler refuses when this differs from the binding the call names; diff --git a/apps/aflow-executor-host/src/__tests__/withdrawalAndLifetime.test.ts b/apps/aflow-executor-host/src/__tests__/withdrawalAndLifetime.test.ts index 37c8980b..74e5b529 100644 --- a/apps/aflow-executor-host/src/__tests__/withdrawalAndLifetime.test.ts +++ b/apps/aflow-executor-host/src/__tests__/withdrawalAndLifetime.test.ts @@ -27,7 +27,8 @@ function makeSession(overrides: Partial = {}): HarnessSession { const id = overrides.id ?? nextSessionRef(); return { id, - ownerRunId: 'run-a', + ownerId: 'run-a', + runId: 'run-a', logicalExecutionId: `step:${id}`, bindingId: 'hb', harnessId: 'claude', diff --git a/apps/aflow-executor-host/src/handlers/harnessHandlers.ts b/apps/aflow-executor-host/src/handlers/harnessHandlers.ts index 455ff931..d57fabff 100644 --- a/apps/aflow-executor-host/src/handlers/harnessHandlers.ts +++ b/apps/aflow-executor-host/src/handlers/harnessHandlers.ts @@ -597,33 +597,35 @@ async function runHarness( await discardScratch(stale); } + const sessionOwner = ctx.startingConversationId ?? ctx.runId; let session: HarnessSession | undefined; let resuming = false; if (input.continueFrom !== undefined) { - session = ownedSession(input.continueFrom, ctx.runId); + session = ownedSession(input.continueFrom, sessionOwner); if (session === undefined) { // The same answer a session that never existed would get: naming a - // session as another run's confirms it exists. This run's own session - // that idled out, was lost to a restart, lost its checkout to a failed - // re-preparation or went with its folder's grant reaches this branch - // too, so the rule states how a kept session ends and guesses at none of - // those for this ref. + // session as another conversation's confirms it exists. This + // conversation's own session that idled out, was lost to a restart, + // lost its checkout to a failed re-preparation or went with its + // folder's grant reaches this branch too, so the rule states how a kept + // session ends and guesses at none of those for this ref. return await failureWithError( ctx, validationError( `No session \`${input.continueFrom}\` is held for this run. A kept session is owned ` + - 'by the run that made it, and every commission is a new run, so `continueFrom` ' + - 'can name only a session this same run made; a session made by any other run is ' + - 'refused as one that does not exist. A kept session ends once it has been idle ' + - 'for an hour, once this executor has restarted since it was made, once a turn ' + - "naming a new `base` has failed to prepare the session's checkout again, or once " + - 'the grant of its connected folder is withdrawn.', + 'by the conversation that started the run that made it, so `continueFrom` can ' + + 'name a session made by any run that same conversation started; a run no ' + + 'conversation started owns the sessions it makes itself. A session owned by any ' + + 'other conversation or run is refused as one that does not exist. A kept session ' + + 'ends once it has been idle for an hour, once this executor has restarted since ' + + "it was made, once a turn naming a new `base` has failed to prepare the session's " + + 'checkout again, or once the grant of its connected folder is withdrawn.', ), ); } if (session.bindingId !== binding.id) { - // Ownership by run alone let a run holding two bindings resume one - // session while naming the other: the sandbox would combine this + // Ownership alone would let a conversation holding two bindings resume + // one session while naming the other: the sandbox would combine this // binding's root with that session's worktree, and a permission change // on the session's own binding would not apply. Process handles are // scoped both ways; a session is a longer-lived handle and needs it more. @@ -662,7 +664,7 @@ async function runHarness( // were made there already; making them again would discard its work. let continuesAttempt = false; if (input.continueFrom === undefined && ctx.attempt > 1 && canContinue) { - const earlier = sessionForRetry(ctx.runId, ctx.logicalExecutionId); + const earlier = sessionForRetry(sessionOwner, ctx.runId, ctx.logicalExecutionId); if ( earlier?.bindingId === binding.id && earlier.harnessId === profile.id && @@ -894,7 +896,8 @@ async function runHarness( session = { busy: true, id: nextSessionRef(), - ownerRunId: ctx.runId, + ownerId: sessionOwner, + runId: ctx.runId, logicalExecutionId: ctx.logicalExecutionId, bindingId: binding.id, harnessId: profile.id, diff --git a/apps/aflow-executor-host/src/harnessSessions.ts b/apps/aflow-executor-host/src/harnessSessions.ts index ccce67cf..1d9c0821 100644 --- a/apps/aflow-executor-host/src/harnessSessions.ts +++ b/apps/aflow-executor-host/src/harnessSessions.ts @@ -21,18 +21,20 @@ import type { BaseMerge } from './baseMerge.js'; export interface HarnessSession { readonly id: string; /** - * The run that may continue it. Ownership works exactly as it does for a - * process handle. + * Who may continue it: the conversation that started the run that made it, + * as the orchestrator stamped it on the job, and the run itself where no + * conversation started one. * - * What that identifies decides whether continuation is usable at all, and it - * differs by path: a chat session is one id across every turn, so a - * conversation continues the way a person expects; a workflow carries its own - * run id across its tasks. An agent working inside a per-attempt worker - * session gets a fresh id each attempt and so cannot name a session across - * one. A retry of the harness run itself is the exception, and is found by - * `logicalExecutionId` rather than named. + * So every commission a conversation starts can continue a session another + * of them made, and no other conversation can. A workflow no conversation + * started still shares one across its own tasks. On a session's own step the + * run is that session: a chat is one id across every turn and so the + * conversation itself, while an agent working inside a per-attempt worker + * session gets a fresh id each attempt and cannot name a session across one. */ - readonly ownerRunId: string; + readonly ownerId: string; + /** The run that made it, which with `logicalExecutionId` names the work a retry continues. */ + readonly runId: string; /** * The work this session was started for, as the executor runtime names it * across every attempt at it. A retry of that work continues this @@ -104,19 +106,29 @@ export function recordSession(session: HarnessSession): void { * The one place ownership is decided, returning the session rather than a * boolean so a caller cannot hold one it did not prove it owns. */ -export function ownedSession(sessionRef: string, ownerRunId: string): HarnessSession | undefined { +export function ownedSession(sessionRef: string, ownerId: string): HarnessSession | undefined { const session = sessions.get(sessionRef); if (session === undefined) return undefined; - return session.ownerRunId === ownerRunId ? session : undefined; + return session.ownerId === ownerId ? session : undefined; } -/** The session an earlier attempt at the same work left, for a retry of it to continue. */ +/** + * The session an earlier attempt at the same work left, for a retry of it to + * continue. The run is matched as well as the owner because a workflow names + * its work by task id: every commission a conversation starts is the same + * `task:commission`, and a retry of one must not wake in another's checkout. + */ export function sessionForRetry( - ownerRunId: string, + ownerId: string, + runId: string, logicalExecutionId: string, ): HarnessSession | undefined { for (const session of sessions.values()) { - if (session.ownerRunId === ownerRunId && session.logicalExecutionId === logicalExecutionId) { + if ( + session.ownerId === ownerId && + session.runId === runId && + session.logicalExecutionId === logicalExecutionId + ) { return session; } } diff --git a/apps/aflow-orchestrator/src/services/SessionOrchestrator/scheduling/workflowTimerJob.ts b/apps/aflow-orchestrator/src/services/SessionOrchestrator/scheduling/workflowTimerJob.ts index cca344c7..18c1076f 100644 --- a/apps/aflow-orchestrator/src/services/SessionOrchestrator/scheduling/workflowTimerJob.ts +++ b/apps/aflow-orchestrator/src/services/SessionOrchestrator/scheduling/workflowTimerJob.ts @@ -36,5 +36,8 @@ export function buildWorkflowTimerStepJob( ...(timer.activatedByPerson !== undefined ? { activatedByPerson: timer.activatedByPerson } : {}), + ...(timer.startingConversationId !== undefined + ? { startingConversationId: timer.startingConversationId } + : {}), }; } diff --git a/apps/aflow-orchestrator/src/services/cybernetic/harness/__tests__/operationTaskDispatch.test.ts b/apps/aflow-orchestrator/src/services/cybernetic/harness/__tests__/operationTaskDispatch.test.ts index 6a4af0f5..7052a5e3 100644 --- a/apps/aflow-orchestrator/src/services/cybernetic/harness/__tests__/operationTaskDispatch.test.ts +++ b/apps/aflow-orchestrator/src/services/cybernetic/harness/__tests__/operationTaskDispatch.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import type { TenantId, TraceId } from '@aflow/schemas'; +import type { SessionId, TenantId, TimerItem, TraceId } from '@aflow/schemas'; const mockScheduleShardTimer = vi.fn(); const mockAddStepJob = vi.fn(); @@ -20,7 +20,9 @@ import { dispatchClaimedOperationTask, operationTaskClaimDueAt, resolveOperationTaskSnoozeDelayMs, + startingConversationOf, } from '../operationTaskDispatch.js'; +import { buildWorkflowTimerStepJob } from '../../../SessionOrchestrator/scheduling/workflowTimerJob.js'; const TENANT = 'a0000000-0000-0000-0000-000000000001' as TenantId; const RUN_ID = '22222222-2222-4222-9222-222222222222'; @@ -248,3 +250,72 @@ describe('dispatchClaimedOperationTask', () => { expect('credentialOwnerId' in job).toBe(false); }); }); + +/** + * Who owns what a task keeps on the executor is the conversation that started + * the task's run, and the executor reads it from the job rather than working + * it out. A run no conversation started carries none, and the executor falls + * back to the run. + */ +describe('the conversation that started a run', () => { + const CONVERSATION = '77777777-7777-4777-9777-777777777777' as SessionId; + + it("is the run's anchor session for a run a conversation started", () => { + expect(startingConversationOf({ sessionId: CONVERSATION, evalBatchId: null })).toBe( + CONVERSATION, + ); + }); + + it('is none for a run with no anchor session, or an eval trial whose anchor was minted for it', () => { + expect(startingConversationOf({ sessionId: null, evalBatchId: null })).toBeUndefined(); + expect(startingConversationOf({ sessionId: '', evalBatchId: null })).toBeUndefined(); + expect( + startingConversationOf({ sessionId: CONVERSATION, evalBatchId: 'batch-1' }), + ).toBeUndefined(); + }); + + it("rides the operation task's job, and the timer a delayed task is armed with", async () => { + await dispatchClaimedOperationTask(deps, { + ...baseArgs('host.harness.run'), + startingConversationId: CONVERSATION, + }); + await dispatchClaimedOperationTask(deps, { + ...baseArgs(SNOOZE_OPERATION_ID, 5_000), + startingConversationId: CONVERSATION, + }); + const job = mockAddStepJob.mock.calls[0]![1] as Record; + expect(job['startingConversationId']).toBe(CONVERSATION); + expect(mockScheduleShardTimer.mock.calls[0]![1]).toMatchObject({ + startingConversationId: CONVERSATION, + }); + }); + + it('is absent from the job of a run no conversation started', async () => { + await dispatchClaimedOperationTask(deps, baseArgs('host.harness.run')); + const job = mockAddStepJob.mock.calls[0]![1] as Record; + expect('startingConversationId' in job).toBe(false); + }); + + it('is carried by the job a workflow timer dispatches', () => { + const timer = { + tenantId: TENANT, + stepExecutionId: WORKER_SESSION_ID, + stepId: 'wait', + operationId: 'host.harness.run', + stepType: 'host', + reason: 'delayed_start', + attempt: 2, + inputRef: INPUT_REF, + traceId: TRACE_ID, + dueAtMs: 1, + } as unknown as TimerItem; + const execution = { runId: RUN_ID, taskId: 'wait', attempt: 2, dispatchAttemptToken: TOKEN }; + expect( + buildWorkflowTimerStepJob({ ...timer, startingConversationId: CONVERSATION }, execution, 2) + .startingConversationId, + ).toBe(CONVERSATION); + expect(buildWorkflowTimerStepJob(timer, execution, 2)).not.toHaveProperty( + 'startingConversationId', + ); + }); +}); diff --git a/apps/aflow-orchestrator/src/services/cybernetic/harness/dispatchRetriedTask.ts b/apps/aflow-orchestrator/src/services/cybernetic/harness/dispatchRetriedTask.ts index 2e2b54e5..81aee793 100644 --- a/apps/aflow-orchestrator/src/services/cybernetic/harness/dispatchRetriedTask.ts +++ b/apps/aflow-orchestrator/src/services/cybernetic/harness/dispatchRetriedTask.ts @@ -22,6 +22,7 @@ import { operationTaskClaimDueAt, describeOperationTaskDispatch, resolveOperationTaskSnoozeDelayMs, + startingConversationOf, type OperationTaskDispatchMode, } from './operationTaskDispatch.js'; @@ -142,6 +143,7 @@ export async function dispatchRetriedTask( }); const traceId = randomUUID() as TraceId; + const startingConversationId = startingConversationOf(run); let dispatchMode: OperationTaskDispatchMode; try { dispatchMode = await dispatchClaimedOperationTask(deps, { @@ -158,6 +160,7 @@ export async function dispatchRetriedTask( snoozeDelayMs, ...(credentialOwnerId !== undefined ? { credentialOwnerId } : {}), activatedByPerson: taskAuthority.activatedByPerson, + ...(startingConversationId !== undefined ? { startingConversationId } : {}), }); } catch (err) { const reason = err instanceof Error ? err.message : String(err); diff --git a/apps/aflow-orchestrator/src/services/cybernetic/harness/dispatchTask.ts b/apps/aflow-orchestrator/src/services/cybernetic/harness/dispatchTask.ts index 58a39a68..0cdc58e4 100644 --- a/apps/aflow-orchestrator/src/services/cybernetic/harness/dispatchTask.ts +++ b/apps/aflow-orchestrator/src/services/cybernetic/harness/dispatchTask.ts @@ -30,6 +30,7 @@ import { operationTaskClaimDueAt, describeOperationTaskDispatch, resolveOperationTaskSnoozeDelayMs, + startingConversationOf, type OperationTaskDispatchMode, } from './operationTaskDispatch.js'; @@ -201,6 +202,7 @@ export async function dispatchTask(deps: HarnessDeps, args: DispatchTaskArgs): P }); const traceId = randomUUID() as TraceId; + const startingConversationId = startingConversationOf(run); let dispatchMode: OperationTaskDispatchMode; try { dispatchMode = await dispatchClaimedOperationTask(deps, { @@ -219,6 +221,7 @@ export async function dispatchTask(deps: HarnessDeps, args: DispatchTaskArgs): P ? { credentialOwnerId: taskAuthority.credentialOwnerId } : {}), activatedByPerson: taskAuthority.activatedByPerson, + ...(startingConversationId !== undefined ? { startingConversationId } : {}), }); } catch (err) { const reason = err instanceof Error ? err.message : String(err); diff --git a/apps/aflow-orchestrator/src/services/cybernetic/harness/operationTaskDispatch.ts b/apps/aflow-orchestrator/src/services/cybernetic/harness/operationTaskDispatch.ts index 4eb85af0..1acd6e61 100644 --- a/apps/aflow-orchestrator/src/services/cybernetic/harness/operationTaskDispatch.ts +++ b/apps/aflow-orchestrator/src/services/cybernetic/harness/operationTaskDispatch.ts @@ -10,6 +10,7 @@ import type { } from '@aflow/schemas'; import { SNOOZE_OPERATION_ID, resolveSnoozeDelayMs } from '@aflow/schemas'; import type { PayloadStore } from '@aflow/payload-store'; +import type { WorkflowRunSummary } from '@aflow/cybernetic-runtime'; import { scheduleShardTimer } from '@aflow/redis'; import { dispatchInlineOp } from '../../SessionOrchestrator/handlers/dispatchInlineOp.js'; import { dispatchOrWaitOnExecutor } from '../../SessionOrchestrator/scheduling/executorWait.js'; @@ -83,6 +84,24 @@ export interface DispatchClaimedOperationTaskArgs { credentialOwnerId?: string; /** The workflow anchor's, from `resolveWorkflowTaskAuthority`. */ activatedByPerson: boolean; + /** From `startingConversationOf`; absent when no conversation started the run. */ + startingConversationId?: SessionId; +} + +/** + * The conversation that started a workflow run, for its operation tasks' jobs + * to carry: the run's anchor session, which `workflow.run.start` records as the + * session that called it — or, for a run a task started, as the one that + * started the task's run. An eval trial's anchor is a session minted for that + * trial alone and is no conversation, so a trial carries none. + */ +export function startingConversationOf( + run: Pick, +): SessionId | undefined { + if (run.evalBatchId !== null || run.sessionId === null || run.sessionId === '') { + return undefined; + } + return run.sessionId as SessionId; } /** @@ -111,6 +130,7 @@ export async function dispatchClaimedOperationTask( snoozeDelayMs, credentialOwnerId, activatedByPerson, + startingConversationId, } = args; const stepType = operationId.split('.')[0] as StepType; const workflowExecution = { runId, taskId, attempt, dispatchAttemptToken }; @@ -131,6 +151,7 @@ export async function dispatchClaimedOperationTask( ...(credentialOwnerId !== undefined ? { credentialOwnerId } : {}), spaceId, activatedByPerson, + ...(startingConversationId !== undefined ? { startingConversationId } : {}), }); return 'snooze_timer'; } @@ -206,6 +227,7 @@ export async function dispatchClaimedOperationTask( ...(credentialOwnerId !== undefined ? { credentialOwnerId } : {}), spaceId, activatedByPerson, + ...(startingConversationId !== undefined ? { startingConversationId } : {}), }); return dispatched.kind === 'waiting' ? 'executor_wait' : 'enqueued'; } diff --git a/apps/aflow-orchestrator/src/services/cybernetic/harness/pollPolicy.ts b/apps/aflow-orchestrator/src/services/cybernetic/harness/pollPolicy.ts index c5d934aa..b20ed9ec 100644 --- a/apps/aflow-orchestrator/src/services/cybernetic/harness/pollPolicy.ts +++ b/apps/aflow-orchestrator/src/services/cybernetic/harness/pollPolicy.ts @@ -20,7 +20,7 @@ import type { } from '@aflow/schemas'; import { getOrchestratorLogger } from '../../../lib/orchestratorLogger.js'; import { attendedAsActingRun } from '../../SessionOrchestrator/handlers/inlineOps/actingRun.js'; -import { DISPATCH_PENDING_INTERVAL_MS } from './operationTaskDispatch.js'; +import { DISPATCH_PENDING_INTERVAL_MS, startingConversationOf } from './operationTaskDispatch.js'; import type { HarnessDeps, WorkflowExecutionRef, WorkflowTaskOutcome } from './types.js'; // ============================================================================ @@ -283,6 +283,7 @@ export async function applyPollGate( } const nextToken = `dispatch:${runId}:${taskId}:${String(attempt)}:poll:${String(nextCycle)}`; + const startingConversationId = startingConversationOf(run); try { await scheduleShardTimer(deps.redis, { tenantId, @@ -299,6 +300,7 @@ export async function applyPollGate( spaceId: run.spaceId, ...(credentialOwnerId !== undefined ? { credentialOwnerId } : {}), activatedByPerson: attendedAsActingRun(helmsmanState), + ...(startingConversationId !== undefined ? { startingConversationId } : {}), }); } catch (err) { // Timer arm failed AFTER the CAS — the re-armed completion_pending row diff --git a/docs/plans/315-local-first-run-ergonomics.md b/docs/plans/315-local-first-run-ergonomics.md index 1ea86100..95289c2f 100644 --- a/docs/plans/315-local-first-run-ergonomics.md +++ b/docs/plans/315-local-first-run-ergonomics.md @@ -294,7 +294,7 @@ Production-build heap and mount time for a long conversation with the Workbench | F118 | platform | After a merge that added an export to `@aflow/schemas`, `yarn start` printed "Stale dist/ in 8 package(s) — safe to ignore in dev (ts-source resolves to src/ directly)" and the web app failed on its first page: the Next application resolves packages through `dist`, so the stale build the message dismissed was the one the browser loaded (`Export browserApprovalActionPhrase doesn't exist in target module`), while every Node service, on `ts-source`, was fine; the operator saw the error, the stack reported health | P4e (the start rebuilds every stale package the web application reads before it serves it, or runs them in watch as it does `@aflow/web-product`, and the stale-dist line names the packages and says which consumer reads `dist`; a dev health check requests the web app's first page and fails loudly on a module error) | | F119 | skill | A review that reached its verdict and lost it writing the result — the harness's last call met the provider's 429 — failed the publication's review task with no verdict, and the publication paused at `approve-push` with its commit already on the branch; the remedy taken was to cancel it, which stranded the commit (F117), and the right one — resuming the publication with the failed review task retried — was named by no surface | P4e (a review that fails with no verdict is retried once by the publication itself, as the check is on a timeout (F111), and a publication paused at `approve-push` after a failed review names the retry as its remedy) | | F120 | skill | A cold Helmsman conversation that continued the stream from its plan node commissioned correctly but with none of the loop's standing inputs — no `wait: 'none'`, no model, no base, no turn budget — because those live in the protocol document and in the operator's briefs, not in the skill; the run waited on its commission with the conversation blocked, as every early round did before D13 | P4e (Commission Change's defaults are the protocol's: `wait: 'none'`, `base: origin/main`, the folder's default model, the two-hour timeout and the several-hundred-turn budget, each overridable; a brief names an input only to change it) | -| F121 | executor | The fix round's commission named the F116 commission's kept session (`continueFrom`) and was refused as expired or lost to a restart — neither had happened: a kept session is owned by the run that made it and every commission is a new run, so from a conversation a commission can never continue another's session, while the protocol page says a stream continues one | P4e (a kept session is owned by the conversation that started the run where there is one, so a fix round from the same conversation continues the coding agent's session; the refusal names the owner, not an expiry) | +| F121 | executor | The fix round's commission named the F116 commission's kept session (`continueFrom`) and was refused as expired or lost to a restart — neither had happened: a kept session is owned by the run that made it and every commission is a new run, so from a conversation a commission can never continue another's session, while the protocol page says a stream continues one | P4e (a kept session is owned by the conversation that started the run that made it, which the orchestrator stamps on the run's operation-task jobs as `startingConversationId`, so a fix round from that conversation continues the coding agent's session; a run no conversation started owns its own; a retry continues only its run's session; another conversation's session is refused as a missing one is, stating the conversation rule and how a kept session ends) | | F122 | skill | Before the fix round's commission took, Helmsman put `wait` among the task's inputs and then `timeoutMs` at the run level, each refused with a teaching error, two turns spent; and with the commission running it asked whether to publish when it landed, a question the brief had answered, holding the round until a person chose | F120 (an input a brief need not name cannot be misplaced); the skill's step after a commission's end is the publication, not a question | | F123 | executor | The F113/F117 slice's own publication was refused at the push twice over: `main` moved during its run, the scan receipt was refused for the range (F113), and when the operator re-scanned the pushed range by hand the check receipt was refused next, because it binds to the base as the scan's does and a re-check against the moved base is the folder's forty minutes; both refusals name their remedy — `host.commit.scan`, `host.commit.check` — to the agent that reads them, and that agent is refused their promotion (`out_of_scope`), so the commit went up with a plain `git push` from the folder | P4e (the publication re-scans and re-checks at push time itself, or the ops are promotable to the agent that reads the refusal, or the refusal names a remedy that reader has; tests cover the wording of both refusals) | | F124 | executor | A merge round's publication scanned `main..head` and read the merge commit against each of its parents, so `main`'s own content — a binary already on `main` — counted as added and unread, the scan was unclean, the review was skipped and the push asked the operator; every later publication of that branch meets it again, because the merge commit stays in the range | P4e (the scan reads what the range adds relative to the push base, a commit's own diff against its first parent and never a merge's second-parent diff; a receipt's `unscanned` names only files the range itself adds) | diff --git a/packages/executor-runtime/src/__tests__/startingConversation.test.ts b/packages/executor-runtime/src/__tests__/startingConversation.test.ts new file mode 100644 index 00000000..36139e15 --- /dev/null +++ b/packages/executor-runtime/src/__tests__/startingConversation.test.ts @@ -0,0 +1,57 @@ +/** + * The conversation that started a workflow task's run reaches the handler as + * the orchestrator stamped it on the job, and only so: a job carrying none + * gives the handler none, whatever its run is, so nothing downstream can take + * the run for a conversation. + */ +import { describe, expect, it } from 'vitest'; +import type { StepJobMessage } from '@aflow/schemas'; +import { buildExecutionContext } from '../executor/buildContext.js'; +import type { ExecutorDependencies, ExecutorLogger } from '../types.js'; + +const RUN = 'e0000000-0000-4000-8000-000000000005'; +const CONVERSATION = 'f0000000-0000-4000-8000-000000000006'; + +const log: ExecutorLogger = { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} }; +const deps = { payloadStore: {} as never, redis: {} as never } as ExecutorDependencies; + +function workflowJob(overrides: Partial = {}): StepJobMessage { + return { + messageVersion: 1, + tenantId: 'a0000000-0000-4000-8000-000000000001', + workflowExecution: { + runId: RUN, + taskId: 'commission', + attempt: 1, + dispatchAttemptToken: `dispatch:${RUN}:commission:1`, + }, + stepExecutionId: 'c0000000-0000-4000-8000-000000000003', + stepId: 'commission', + stepType: 'host', + operationId: 'host.harness.run', + attempt: 1, + idempotencyKey: 'idem-1', + inputRef: 'inline:e30=', + traceId: 'trace-1', + scheduledAtMs: 1, + ...overrides, + } as StepJobMessage; +} + +describe('the conversation that started a workflow task’s run', () => { + it('is on the context as the job carries it, beside the run', async () => { + const ctx = await buildExecutionContext( + deps, + workflowJob({ startingConversationId: CONVERSATION as never }), + log, + ); + expect(ctx.startingConversationId).toBe(CONVERSATION); + expect(ctx.runId).toBe(RUN); + }); + + it('is absent from the context when the job carries none', async () => { + const ctx = await buildExecutionContext(deps, workflowJob(), log); + expect('startingConversationId' in ctx).toBe(false); + expect(ctx.runId).toBe(RUN); + }); +}); diff --git a/packages/executor-runtime/src/executor/buildContext.ts b/packages/executor-runtime/src/executor/buildContext.ts index 60d631c0..66dd6a3e 100644 --- a/packages/executor-runtime/src/executor/buildContext.ts +++ b/packages/executor-runtime/src/executor/buildContext.ts @@ -136,6 +136,9 @@ export async function buildExecutionContext( runId: executionRunId, stepExecutionId: job.stepExecutionId as StepExecutionId, logicalExecutionId: deriveLogicalExecutionId(job), + ...(job.startingConversationId !== undefined + ? { startingConversationId: job.startingConversationId } + : {}), attempt: job.attempt, idempotencyKey: job.idempotencyKey, traceId: job.traceId, diff --git a/packages/executor-runtime/src/types.ts b/packages/executor-runtime/src/types.ts index d409d634..aca44cf6 100644 --- a/packages/executor-runtime/src/types.ts +++ b/packages/executor-runtime/src/types.ts @@ -111,6 +111,13 @@ export interface ExecutorContext { */ readonly logicalExecutionId: string; + /** + * The conversation that started this workflow task's run, as the + * orchestrator stamped it on the job. Absent on a session's own steps and on + * a run no conversation started; a handler never infers it from `runId`. + */ + readonly startingConversationId?: SessionId; + /** Current attempt number */ readonly attempt: number; diff --git a/packages/platform-artifacts/src/skillCatalog/commissionChange.ts b/packages/platform-artifacts/src/skillCatalog/commissionChange.ts index 9007f6f2..1fe87df3 100644 --- a/packages/platform-artifacts/src/skillCatalog/commissionChange.ts +++ b/packages/platform-artifacts/src/skillCatalog/commissionChange.ts @@ -24,13 +24,13 @@ const MINUTES = 60_000; const COMMISSION_CHANGE: SkillCatalogEntry = { catalogId: 'commission-change', - version: 4, + version: 5, name: 'Commission Change', tagline: "Have the machine's own coding agent make a change in a connected repository, and get the change back as a patch.", description: `Fits a request to make a change in a repository on the operator's machine — "build this slice", "fix these findings", "now fix the test that broke". A commission hands a brief to the coding agent installed on that machine, which works in an isolated checkout of the connected folder and leaves the folder itself untouched; what it changed comes back as a stored patch. Nothing is committed, pushed or published: publishing the patch is Publish Local Changes, and reviewing it once published is Review Local Changes. -**What it needs**: the connected folder and the brief — the task text, everything the coding agent needs and nothing it has to guess: the files and the lines where they are known, what is wrong and what right looks like, the tests to add, the checks to run and what its result reports. \`base\` is where the checkout starts: \`origin/main\` for new work, the branch's name for a fix appended to a branch with an open pull request. A fix to a branch \`main\` has moved past also takes \`mergeFrom: origin/\`, so the checkout merges it first and the agent resolves what conflicts. A further turn on the same work takes \`continueFrom\`, the \`sessionRef\` the earlier run reported. \`harness\`, \`model\`, \`maxTurns\` and \`timeoutMs\` are optional. \`harness\` is needed only on a machine that offers more than one coding agent, which refuses a commission that names none and lists the ones it offers. The budgets are sized for a slice already — ${String(COMMISSION_TIMEOUT_DEFAULT_MS / MINUTES)} minutes, and ${String(HOST_HARNESS_MAX_TURNS_DEFAULT)} turns on a coding agent that takes a turn budget — and are named only to change them. +**What it needs**: the connected folder and the brief — the task text, everything the coding agent needs and nothing it has to guess: the files and the lines where they are known, what is wrong and what right looks like, the tests to add, the checks to run and what its result reports. \`base\` is where the checkout starts: \`origin/main\` for new work, the branch's name for a fix appended to a branch with an open pull request. A fix to a branch \`main\` has moved past also takes \`mergeFrom: origin/\`, so the checkout merges it first and the agent resolves what conflicts. A further turn on the same work takes \`continueFrom\`, the \`sessionRef\` the earlier run reported, from the conversation that started that run: a session belongs to that conversation, and a commission from any other is refused it. \`harness\`, \`model\`, \`maxTurns\` and \`timeoutMs\` are optional. \`harness\` is needed only on a machine that offers more than one coding agent, which refuses a commission that names none and lists the ones it offers. The budgets are sized for a slice already — ${String(COMMISSION_TIMEOUT_DEFAULT_MS / MINUTES)} minutes, and ${String(HOST_HARNESS_MAX_TURNS_DEFAULT)} turns on a coding agent that takes a turn budget — and are named only to change them. **Start it with \`wait: 'none'\`.** A commission runs for tens of minutes. Started that way it returns the run id at once, the conversation stays free, and the commission's end wakes it with the result. Several commissions may run at once on one machine, each in its own checkout. The machine runs only so many coding agents at once — ${String(HOST_HARNESS_CONCURRENCY_DEFAULT)} unless its operator chose another number — and a commission past that waits for one to end rather than being refused; its time counts from when it starts. @@ -90,7 +90,7 @@ const COMMISSION_CHANGE: SkillCatalogEntry = { id: 'continueFrom', required: false, description: - 'The `sessionRef` an earlier commission reported, to add a turn to that work rather than start afresh: its checkout and everything the coding agent remembers of it are still there. Only the run that started a session can continue it.', + 'The `sessionRef` an earlier commission reported, to add a turn to that work rather than start afresh: its checkout and everything the coding agent remembers of it are still there. A session belongs to the conversation that started the commission that made it: any commission that conversation starts can continue it, and one started from another conversation cannot.', schema: { type: 'string', minLength: 1 }, }, { diff --git a/packages/platform-artifacts/src/storeCatalogHashes.json b/packages/platform-artifacts/src/storeCatalogHashes.json index 0dc48e08..2de6b025 100644 --- a/packages/platform-artifacts/src/storeCatalogHashes.json +++ b/packages/platform-artifacts/src/storeCatalogHashes.json @@ -100,7 +100,7 @@ "contentHash": "cd6f27ade9b8f319bd43cafed7e48afffb8412148560638f5181fabb505f8815" }, "local-commission": { - "version": 4, + "version": 5, "contentHash": "35998b7e73fb024809520f7738c4f169e18858a800d924baf195f43e6648a90a" }, "local-publish": { diff --git a/packages/schemas/src/operations/host.ts b/packages/schemas/src/operations/host.ts index 59288e21..16968fd7 100644 --- a/packages/schemas/src/operations/host.ts +++ b/packages/schemas/src/operations/host.ts @@ -941,8 +941,10 @@ const HostHarnessRunInputObjectSchema = z.object({ .describe( 'A `sessionRef` from an earlier run, to add a turn to that conversation instead of ' + 'starting one. The earlier checkout and everything the harness remembers about it ' + - 'are still there, so "now fix the test that broke" works as an instruction. Only ' + - 'the run that started a session can continue it.', + 'are still there, so "now fix the test that broke" works as an instruction. A ' + + 'session is owned by the conversation that started the run that made it, so any ' + + 'run that conversation starts can continue it; a run no conversation started owns ' + + 'its sessions itself.', ), maxTurns: z .number() diff --git a/packages/schemas/src/runtime/streamMessages.ts b/packages/schemas/src/runtime/streamMessages.ts index 0b9a3023..23c24f43 100644 --- a/packages/schemas/src/runtime/streamMessages.ts +++ b/packages/schemas/src/runtime/streamMessages.ts @@ -579,6 +579,15 @@ export const StepJobMessageSchema = z */ activatedByPerson: z.boolean().optional(), + /** + * The conversation that started this workflow task's run: the session whose + * `workflow.run.start` made the run, or made the run whose task started it. + * Stamped by the orchestrator from the run's record and never from the + * step's input. Absent on a session's own steps, whose session is + * `sessionId`, and on a run no conversation started. + */ + startingConversationId: SessionIdSchema.optional(), + /** * Where this step sits in the sequence its agent decided on. Present only * for tool steps lowered from an agent turn; a workflow operation task has @@ -759,6 +768,9 @@ export const TimerItemSchema = z */ activatedByPerson: z.boolean().optional(), + /** A workflow task's `startingConversationId`, for the job this timer dispatches. */ + startingConversationId: SessionIdSchema.optional(), + /** * What an `executor_wait` timer dispatches, whole, since when it has * waited, and how many of its looks it has taken. The job is carried From 1d165a844369425310cd50701fef8562573a0600 Mon Sep 17 00:00:00 2001 From: karim Date: Sat, 17 Oct 2026 16:05:15 +0200 Subject: [PATCH 2/3] =?UTF-8?q?fix(315):=20F121=20round=201=20=E2=80=94=20?= =?UTF-8?q?the=20refusal=20leads=20with=20the=20conversation=20rule,=20the?= =?UTF-8?q?=20retry=20and=20poll=20stamping=20sites=20are=20tested,=20and?= =?UTF-8?q?=20the=20stale=20ownerRunId=20literal=20goes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/__tests__/harnessContinueFrom.test.ts | 16 +-- .../harnessConversationOwnership.test.ts | 2 +- .../src/__tests__/worktree.test.ts | 3 +- .../src/handlers/harnessHandlers.ts | 10 +- .../__tests__/dispatchRetriedTask.test.ts | 104 ++++++++++++++++++ .../harness/__tests__/pollPolicy.test.ts | 18 ++- 6 files changed, 139 insertions(+), 14 deletions(-) create mode 100644 apps/aflow-orchestrator/src/services/cybernetic/harness/__tests__/dispatchRetriedTask.test.ts diff --git a/apps/aflow-executor-host/src/__tests__/harnessContinueFrom.test.ts b/apps/aflow-executor-host/src/__tests__/harnessContinueFrom.test.ts index c3fea93b..716d6339 100644 --- a/apps/aflow-executor-host/src/__tests__/harnessContinueFrom.test.ts +++ b/apps/aflow-executor-host/src/__tests__/harnessContinueFrom.test.ts @@ -133,12 +133,17 @@ async function commission( } describe('a commission that names a session it cannot continue', () => { - it('is told the ownership rule', async () => { + it('is told the ownership rule before anything else', async () => { const { status, message } = await commission(OTHER_CONVERSATIONS); expect(status).toBe('FAILED'); - expect(message).toContain( - 'A kept session is owned by the conversation that started the run that made it', - ); + expect( + message.startsWith( + 'A kept session is owned by the conversation that started the run that made it, and no ' + + `session \`${OTHER_CONVERSATIONS}\` is held for the conversation that started this run, ` + + 'or for this run where no conversation started it. ', + ), + ).toBe(true); + expect(message).not.toContain('held for this run.'); expect(message).toContain( '`continueFrom` can name a session made by any run that same conversation started', ); @@ -173,9 +178,6 @@ describe('a commission that names a session it cannot continue', () => { it('makes no guess about why the ref it named in particular is not held', async () => { const { message } = await commission(OTHER_CONVERSATIONS); expect(message).not.toMatch(/\b(may|might|probably|perhaps|likely)\b/i); - expect(message.startsWith(`No session \`${OTHER_CONVERSATIONS}\` is held for this run. `)).toBe( - true, - ); expect(message.split(OTHER_CONVERSATIONS)).toHaveLength(2); expect(message).not.toContain('conversation-other'); expect(message).not.toContain('run-other-conversation'); diff --git a/apps/aflow-executor-host/src/__tests__/harnessConversationOwnership.test.ts b/apps/aflow-executor-host/src/__tests__/harnessConversationOwnership.test.ts index 7dbbae6b..ee67a6d6 100644 --- a/apps/aflow-executor-host/src/__tests__/harnessConversationOwnership.test.ts +++ b/apps/aflow-executor-host/src/__tests__/harnessConversationOwnership.test.ts @@ -251,7 +251,7 @@ describe('a session kept for a run no conversation started', () => { }); expect(otherRun.status).toBe('FAILED'); - expect(otherRun.message).toContain(`No session \`${sessionRef}\` is held for this run.`); + expect(otherRun.message).toContain(`no session \`${sessionRef}\` is held for`); expect(ownTask.status).toBe('SUCCEEDED'); expect(ownTask.output['continued']).toBe(true); expect(changedFiles(ownTask.output['patch']).sort()).toEqual(['half.txt', 'whole.txt']); diff --git a/apps/aflow-executor-host/src/__tests__/worktree.test.ts b/apps/aflow-executor-host/src/__tests__/worktree.test.ts index 6d87a873..9a1fae37 100644 --- a/apps/aflow-executor-host/src/__tests__/worktree.test.ts +++ b/apps/aflow-executor-host/src/__tests__/worktree.test.ts @@ -474,7 +474,8 @@ describe('checkouts a restart left behind', () => { const live = await prepareWorktree(repo, liveScratch, 'work'); recordSession({ id: 'hs_live', - ownerRunId: 'run-live', + ownerId: 'run-live', + runId: 'run-live', logicalExecutionId: 'step:hs_live', bindingId: 'hb', harnessId: 'edits', diff --git a/apps/aflow-executor-host/src/handlers/harnessHandlers.ts b/apps/aflow-executor-host/src/handlers/harnessHandlers.ts index d57fabff..d3f53e8c 100644 --- a/apps/aflow-executor-host/src/handlers/harnessHandlers.ts +++ b/apps/aflow-executor-host/src/handlers/harnessHandlers.ts @@ -612,10 +612,12 @@ async function runHarness( return await failureWithError( ctx, validationError( - `No session \`${input.continueFrom}\` is held for this run. A kept session is owned ` + - 'by the conversation that started the run that made it, so `continueFrom` can ' + - 'name a session made by any run that same conversation started; a run no ' + - 'conversation started owns the sessions it makes itself. A session owned by any ' + + 'A kept session is owned by the conversation that started the run that made it, ' + + `and no session \`${input.continueFrom}\` is held for the conversation that ` + + 'started this run, or for this run where no conversation started it. ' + + '`continueFrom` can name a session made by any run that same conversation ' + + 'started; a run no conversation started owns the sessions it makes itself. ' + + 'A session owned by any ' + 'other conversation or run is refused as one that does not exist. A kept session ' + 'ends once it has been idle for an hour, once this executor has restarted since ' + "it was made, once a turn naming a new `base` has failed to prepare the session's " + diff --git a/apps/aflow-orchestrator/src/services/cybernetic/harness/__tests__/dispatchRetriedTask.test.ts b/apps/aflow-orchestrator/src/services/cybernetic/harness/__tests__/dispatchRetriedTask.test.ts new file mode 100644 index 00000000..9e715668 --- /dev/null +++ b/apps/aflow-orchestrator/src/services/cybernetic/harness/__tests__/dispatchRetriedTask.test.ts @@ -0,0 +1,104 @@ +/** + * Contract: a retried operation task's job carries the conversation that + * started its run, as the first dispatch's does, so a retry of a commission + * from a conversation continues the session that conversation's runs keep on + * the executor. A run no conversation started carries none. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { SessionId, TenantId } from '@aflow/schemas'; + +const OPERATION = 'host.harness.run'; +const TENANT = 'a0000000-0000-0000-0000-000000000001' as TenantId; +const RUN_ID = '22222222-2222-4222-9222-222222222222'; +const CONVERSATION = '77777777-7777-4777-9777-777777777777'; + +const mockDispatchClaimedOperationTask = vi.fn(); +const mockLoadRun = vi.fn(); + +vi.mock('@aflow/redis', () => ({ + getSessionState: () => Promise.resolve(null), + readSpaceContextGen: () => Promise.resolve(0), +})); + +vi.mock('@aflow/cybernetic-runtime', () => ({ + claimRetriedTask: () => Promise.resolve(true), +})); + +vi.mock('../helpers.js', () => ({ + loadRunByRunIdAcrossSpaces: (...args: unknown[]) => mockLoadRun(...args), + resolveWorkflowForRun: () => + Promise.resolve({ + tasks: [ + { taskId: 'commission', name: 'Commission', type: 'operation', operation: OPERATION }, + ], + }), + emitTaskUpdate: () => Promise.resolve(), +})); + +vi.mock('../taskHelpers.js', () => ({ + buildTaskInputRef: () => Promise.resolve('inline:e30='), + buildDelegateTaskInput: () => Promise.reject(new Error('an operation task builds no delegate')), +})); + +vi.mock('../taskAuthority.js', () => ({ + resolveWorkflowTaskAuthority: () => Promise.resolve({ activatedByPerson: false }), +})); + +vi.mock('../runnerBridge.js', () => ({ + spawnRunnerSession: () => Promise.reject(new Error('an operation task spawns no runner')), +})); + +vi.mock('../operationTaskDispatch.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveOperationTaskSnoozeDelayMs: () => Promise.resolve(0), + dispatchClaimedOperationTask: (...args: unknown[]) => mockDispatchClaimedOperationTask(...args), + }; +}); + +const { dispatchRetriedTask } = await import('../dispatchRetriedTask.js'); + +function runStartedBy(sessionId: string | null) { + return { + runId: RUN_ID, + spaceId: '55555555-5555-4555-9555-555555555555', + workflowSlug: 'commission-change', + workflowRevision: 1, + sessionId, + evalBatchId: null, + metadata: null, + tasks: [], + }; +} + +async function retry(): Promise> { + await dispatchRetriedTask({ db: {}, redis: {}, payloadStore: {} } as never, { + tenantId: TENANT, + runId: RUN_ID, + taskId: 'commission', + attempt: 2, + helmsmanSessionId: CONVERSATION as SessionId, + }); + expect(mockDispatchClaimedOperationTask).toHaveBeenCalledTimes(1); + return mockDispatchClaimedOperationTask.mock.calls[0]![1] as Record; +} + +beforeEach(() => { + mockDispatchClaimedOperationTask.mockReset().mockResolvedValue('enqueued'); + mockLoadRun.mockReset(); +}); + +describe('a retried operation task', () => { + it('carries the conversation that started its run', async () => { + mockLoadRun.mockResolvedValue(runStartedBy(CONVERSATION)); + const dispatched = await retry(); + expect(dispatched['startingConversationId']).toBe(CONVERSATION); + }); + + it('carries none for a run no conversation started', async () => { + mockLoadRun.mockResolvedValue(runStartedBy(null)); + const dispatched = await retry(); + expect(dispatched).not.toHaveProperty('startingConversationId'); + }); +}); diff --git a/apps/aflow-orchestrator/src/services/cybernetic/harness/__tests__/pollPolicy.test.ts b/apps/aflow-orchestrator/src/services/cybernetic/harness/__tests__/pollPolicy.test.ts index 6b23464e..621cf98f 100644 --- a/apps/aflow-orchestrator/src/services/cybernetic/harness/__tests__/pollPolicy.test.ts +++ b/apps/aflow-orchestrator/src/services/cybernetic/harness/__tests__/pollPolicy.test.ts @@ -119,10 +119,11 @@ function gateArgs(over?: { row?: WorkflowTaskRow; def?: WorkflowTask | null; outputRef?: string; + run?: WorkflowRunDetail; }) { return { tenantId: TENANT, - run, + run: over?.run ?? run, taskRow: over?.row ?? taskRow(), taskDef: over?.def === undefined ? taskDef() : over.def, workflowExecution: { @@ -310,6 +311,21 @@ describe('applyPollGate', () => { expect(timer.operationId).toBe('mcp.tool.call'); }); + it("arms the next cycle's timer with the conversation that started the run, and none for a run no conversation started", async () => { + const conversation = '77777777-7777-4777-9777-777777777777'; + const { payloadStore } = makePayloadStore({ status: 'PENDING' }); + const startedBy = (sessionId: string | null) => + ({ ...run, sessionId, evalBatchId: null }) as unknown as WorkflowRunDetail; + + await applyPollGate(deps(payloadStore), gateArgs({ run: startedBy(conversation) })); + await applyPollGate(deps(payloadStore), gateArgs({ run: startedBy(null) })); + + const armed = (mockScheduleShardTimer.mock.calls as unknown[][]).map((call) => call[1]); + expect(armed).toHaveLength(2); + expect(armed[0]).toMatchObject({ startingConversationId: conversation }); + expect(armed[1]).not.toHaveProperty('startingConversationId'); + }); + it('poll-cycle CAS miss → handled (duplicate), timer NOT armed', async () => { mockAdvanceTaskPollCycle.mockResolvedValueOnce(false); const { payloadStore } = makePayloadStore({ status: 'PENDING' }); From ac1483e7b01ab655304f0480d6dfd42edc2a9754 Mon Sep 17 00:00:00 2001 From: karim Date: Sat, 17 Oct 2026 16:21:01 +0200 Subject: [PATCH 3/3] =?UTF-8?q?fix(315):=20F121=20round=202=20=E2=80=94=20?= =?UTF-8?q?a=20retry=20finds=20its=20session=20by=20run=20and=20work=20alo?= =?UTF-8?q?ne,=20so=20a=20recovered=20run=20keeps=20its=20sessions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/__tests__/harnessSessions.test.ts | 26 +- .../src/handlers/harnessHandlers.ts | 2 +- .../src/harnessSessions.ts | 24 +- docs/plans/315-local-first-run-ergonomics.md | 258 +++++++++--------- 4 files changed, 166 insertions(+), 144 deletions(-) diff --git a/apps/aflow-executor-host/src/__tests__/harnessSessions.test.ts b/apps/aflow-executor-host/src/__tests__/harnessSessions.test.ts index 005b6640..8625e079 100644 --- a/apps/aflow-executor-host/src/__tests__/harnessSessions.test.ts +++ b/apps/aflow-executor-host/src/__tests__/harnessSessions.test.ts @@ -79,7 +79,7 @@ describe('a retry finds the session of its own work in its own run', () => { it('finds the session an earlier attempt at the same work in the same run made', () => { const session = makeSession({ logicalExecutionId: 'task:commission' }); recordSession(session); - expect(sessionForRetry('conversation-a', 'run-a', 'task:commission')?.id).toBe(session.id); + expect(sessionForRetry('run-a', 'task:commission')?.id).toBe(session.id); forgetSession(session.id); }); @@ -88,15 +88,31 @@ describe('a retry finds the session of its own work in its own run', () => { // starts is `task:commission`; a retry of one must start in its own checkout. const session = makeSession({ logicalExecutionId: 'task:commission' }); recordSession(session); - expect(sessionForRetry('conversation-a', 'run-b', 'task:commission')).toBeUndefined(); + expect(sessionForRetry('run-b', 'task:commission')).toBeUndefined(); forgetSession(session.id); }); - it("does not find another owner's or other work's session", () => { + it('does not find the session of other work in the same run', () => { const session = makeSession({ logicalExecutionId: 'task:commission' }); recordSession(session); - expect(sessionForRetry('conversation-b', 'run-a', 'task:commission')).toBeUndefined(); - expect(sessionForRetry('conversation-a', 'run-a', 'task:another')).toBeUndefined(); + expect(sessionForRetry('run-a', 'task:another')).toBeUndefined(); + forgetSession(session.id); + }); + + it("finds its run's session though another conversation has since recovered the run", () => { + // A stalled run recovered from conversation B carries B on its later + // attempts, but a retry of it is still the same run: the session that run + // made under conversation A, which started it, is the one it continues. + const session = makeSession({ + ownerId: 'conversation-a', + logicalExecutionId: 'task:commission', + }); + recordSession(session); + expect(sessionForRetry('run-a', 'task:commission')?.id).toBe(session.id); + // Ownership for `continueFrom` does not move with the recovery: the + // session made before it still belongs to the conversation that started the run. + expect(ownedSession(session.id, 'conversation-b')).toBeUndefined(); + expect(ownedSession(session.id, 'conversation-a')?.id).toBe(session.id); forgetSession(session.id); }); }); diff --git a/apps/aflow-executor-host/src/handlers/harnessHandlers.ts b/apps/aflow-executor-host/src/handlers/harnessHandlers.ts index d3f53e8c..ec99cb99 100644 --- a/apps/aflow-executor-host/src/handlers/harnessHandlers.ts +++ b/apps/aflow-executor-host/src/handlers/harnessHandlers.ts @@ -666,7 +666,7 @@ async function runHarness( // were made there already; making them again would discard its work. let continuesAttempt = false; if (input.continueFrom === undefined && ctx.attempt > 1 && canContinue) { - const earlier = sessionForRetry(sessionOwner, ctx.runId, ctx.logicalExecutionId); + const earlier = sessionForRetry(ctx.runId, ctx.logicalExecutionId); if ( earlier?.bindingId === binding.id && earlier.harnessId === profile.id && diff --git a/apps/aflow-executor-host/src/harnessSessions.ts b/apps/aflow-executor-host/src/harnessSessions.ts index 1d9c0821..8e7e7a28 100644 --- a/apps/aflow-executor-host/src/harnessSessions.ts +++ b/apps/aflow-executor-host/src/harnessSessions.ts @@ -31,6 +31,14 @@ export interface HarnessSession { * run is that session: a chat is one id across every turn and so the * conversation itself, while an agent working inside a per-attempt worker * session gets a fresh id each attempt and cannot name a session across one. + * + * It is recorded once, when the session is made. A stalled run recovered from + * another conversation carries that conversation from then on, so the run's + * later sessions belong to the recovering conversation, while the sessions it + * made before the recovery still belong to the one that started it. That is + * the rule, not a gap: whoever recovers a run carries its work forward. + * Ownership decides `continueFrom` only; a retry of the run's own work does + * not consult it (`sessionForRetry`). */ readonly ownerId: string; /** The run that made it, which with `logicalExecutionId` names the work a retry continues. */ @@ -114,21 +122,19 @@ export function ownedSession(sessionRef: string, ownerId: string): HarnessSessio /** * The session an earlier attempt at the same work left, for a retry of it to - * continue. The run is matched as well as the owner because a workflow names - * its work by task id: every commission a conversation starts is the same - * `task:commission`, and a retry of one must not wake in another's checkout. + * continue. The run is matched because a workflow names its work by task id: + * every commission a conversation starts is the same `task:commission`, and a + * retry of one must not wake in another's checkout. The owner is not matched: + * a retry is the same run, so the run already says whose work it is, and a run + * recovered from another conversation carries that conversation on its later + * attempts while its earlier sessions keep the owner they were made under. */ export function sessionForRetry( - ownerId: string, runId: string, logicalExecutionId: string, ): HarnessSession | undefined { for (const session of sessions.values()) { - if ( - session.ownerId === ownerId && - session.runId === runId && - session.logicalExecutionId === logicalExecutionId - ) { + if (session.runId === runId && session.logicalExecutionId === logicalExecutionId) { return session; } } diff --git a/docs/plans/315-local-first-run-ergonomics.md b/docs/plans/315-local-first-run-ergonomics.md index 95289c2f..e8227344 100644 --- a/docs/plans/315-local-first-run-ergonomics.md +++ b/docs/plans/315-local-first-run-ergonomics.md @@ -172,135 +172,135 @@ Production-build heap and mount time for a long conversation with the Workbench ## 6. Findings the phases answer -| # | Surface | Finding | Phase | -| ---- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| F1 | rail | Collapsed rail names nothing; expanding takes a click and pushes content; no accessible names when collapsed | P0 (D9) | -| F2 | chat | Every fresh chat shows "Reconnecting…" although the socket is live; the notice reads a session subscription that does not exist yet | P0 (D6) | -| F3 | chat | Empty-state copy is the hosted product's; nothing about this computer | P2, P3 | -| F4 | rail | "Shop" in the rail, "Store" everywhere else | P0 | -| F5 | web | Report-only CSP `connect-src 'self'` omits the realtime origin | P0 | -| F6 | onboarding | "Workspace ready — ready to run agents" is the only post-create step | P3 | -| F7 | computer | The connect command assumes a source checkout | P1 (D5) | -| F8 | computer | No machine state: paired, executor, coding agent, last seen | P1 | -| F9 | chat | Helmsman's first answer links nothing, says "coding harness", asks for push credentials the local push does not use | P0 copy, P3 data | -| F10 | store | No local-first shelf; listing copy says "harness" | P0 copy, P3 shelf | -| F11 | skills | "Describe one to the cybernetic agent" | P0 | -| F12 | integrations | Repos tab and code-repository option for an absent lane | P0 (D3) | -| F13 | settings | Hosted-only tabs shown; credentials buried; tabs unreviewed | P0 gating, P1 credentials, P2 review | -| F14 | workbench | Nothing about this computer | P1 | -| F15 | chat | Opening a space's chat always starts empty | P2, P3 | -| F16 | chat | The first answer ran a memory query before a how-do-I-start question | P3 (state, not a query) | -| F17 | perf | 644 MB heap for one chat tab in dev | P5 | -| F18 | memory | Embeddings ride a platform credential or fail silently; no per-space provider or model; a switch has no re-index | P3 (D12) | -| F19 | operator | Approvals and questions have no place of their own beside the chat | P2 (D11) | -| F20 | mcp | The MCP dev server needs a hand-made auth file and an `.env` line; the stack already provisions the instance credential it wants | P3 (the stack hands it over) | -| F21 | runner | A second stack started before the first has stopped is killed by the first's port sweep, and `tsx watch` then holds a dead server | P4b (runner refuses a held port) | -| F22 | runner | Boot warns about the dev Redis password and the Postgres connection budget; each is one configuration talking to another's default | P2 review | -| F23 | loop | The first commission on a fresh repository went straight to a well-formed `host.harness.run`; the D10 baseline | §3 measure | -| F24 | loop | A Helmsman waiting on a child run takes no parallel request; the review of a paused publication has to be started by hand | P4b (the publication reviews itself) | -| F25 | loop | One `workflow.run.start` produced two run records and a refusal naming a run the same call had made | P4b | -| F26 | realtime | A tab on `127.0.0.1` was refused the socket; the allowlist named one loopback spelling | delivered (#14) | -| F27 | loop | A kept coding-agent session does not survive an executor restart; every restart drops the analysis a continued commission reuses | P4b (the session record beside the run) | -| F28 | store | A space runs the installed projection of a platform skill; a registry that moved leaves the loop refusing an input the skill has | P3 (platform bundles follow the registry) | -| F29 | op | `host.harness.run` checks `applies` against the folder's tree, so a patch made from a named base reads as a conflict | P4b | -| F30 | store | No update operation for an installed listing; the REST route wants `mode` and a uuid key and its 400 names no field | P3 | -| F31 | runner | Ctrl-C returns the prompt before the runner has finished; its last lines land after it and read as a hang, and `yarn kill` follows | P4b (`start.mjs` waits) | -| F32 | runner | A runner that yields the host lane to an exiting executor never starts its own | P4b | -| F33 | mcp | `start_session` with a platform role and a wrong argument name makes a session that fails on an empty prompt instead of refusing the call | P3 (the MCP refuses at its schema) | -| F34 | loop | A publication waiting on its push approval holds the workflow's single active-run slot, so no other change can be published in the space until it is decided | P4b (the skill declares its concurrency) | -| F35 | action center | The push approval shows the next task's raw input and not the pause prompt; nothing names the commit, its files or a review verdict, and the prompt itself still reads `..` unfilled, so the operator approves blind | P4b (the card renders the prompt and the commit) | -| F36 | loop | A publication whose pull-request call fails after the push cannot re-run that call once the cause is fixed; the pause offers only a hand-written output or failure | P4b (re-execute after the operator's fix) | -| F37 | skill | Local Publish takes `owner` and `repo` as free strings; a repository given as `github.com/aflowai/aflow` reached GitHub as a path and answered 404 after the push | P4b (one `repository` input, normalized from a name or URL) | -| F38 | skill | The commit step's refusal names `base`, the op's field, while the skill's input for that sha is `baseSha` and its `base` is the pull request's target; two callers put the sha in `base` and read the refusal as confirmation | P4b (one name for one thing at both layers) | -| F39 | skill | A review raised a major it could not reproduce (the sandbox refuses a pty) from job-control reasoning, and a five-second test on the machine refuted it; the verdict turned on an inference | P4b (a finding says how it was established; only reproduced or read findings drive the verdict) | -| F40 | skill | A GitHub credential that cannot see the repository surfaces as a raw 404 after the push (the push itself rides the operator's own git); the publication learns of it one step from done, and nothing names the credential | P4b (read the repository before the approval; a 404 on a private repository says which credential) | -| F41 | executor | Every commission and review leaves its detached worktree registered in the operator's repository under the temp directory; ten of them after one evening, four already prunable | P4b (the run removes its worktree; boot prunes the stale ones) | -| F42 | loop | A commission with no base starts at the folder's HEAD, which is as fresh as the operator's last pull; a change made two merges behind main reaches the pull request behind it | P4c (a base may name the remote's branch, fetched first) | -| F43 | skill | A deep review cannot run the repository's tests: its sandbox copy of the branch has no built workspace packages, so vitest fails to resolve them and the new tests go unverified | P4c (the review checks out the range in a worktree of the folder, dependencies linked, as a commission does) | -| F44 | skill | The publication's pull request is authored by the operator's own token, so a ruleset requiring one approving review cannot be satisfied by the only reviewer there is | P4c (the pull request is opened as the space's app installation, the operator reviews it) | -| F45 | skill | The patch rides as an inline run input and `workflow.run.start` caps inputs at 32 KB, so a change over that size cannot be published through the skill at all; the commission's own output already carries it as a payload | P4c (`host.file.patch` takes a payload reference; the skill binds the commission's ref, never the bytes) | -| F46 | skill | A review's range resolves against the folder's local `main`, as fresh as the operator's last pull; a one-commit branch cut from the remote read as twenty commits, three of them merged upstream | P4c (the review reads the range against the remote's base, fetched first, as a commission may) | -| F47 | executor | The ref guard reaches every git a commission or review runs, so the executor's own tests, which commit in repositories they create, cannot run inside either; the guard belongs to the connected folder's repository only | P4c (the hook refuses only refs of the folder it guards) | -| F48 | orchestrator | A `$ref` to a prior output nested inside a tool argument (`inputs.patch`) replaces the whole argument with the referenced value, so a run cannot be started with one input taken by reference; Helmsman retried the same call until it gave up on references | P4c (a nested reference resolves in place; a test with a reference below the top level) | -| F49 | chat | The live coding-agent card the chat shows during a run stays pinned at the bottom after the run ends when the tab watched it live (a reload folds it), and two runs leave two pinned cards: the live card never receives its settled transition | P2 (the live card folds into the transcript at the run's end) | -| F50 | skill | An append onto a branch that already has a pull request still calls create, which GitHub refuses with 422; the run pauses one step from done and the agent supplies the existing number by hand | P4c (a publication reads the branch's open pull request first and updates it) | -| F51 | store | A skill entry's version can move without its bundle's, and the Store compares bundles, so a space installing or updating the bundle may keep the older skill; whether the projection follows the entry is unverified | P3 (the bundle version derives from its entries, or the Store compares entry versions) | -| F52 | op | A publication onto a fresh branch requires the commission's base to be the folder's HEAD, so a commission started from the remote (the answer to F42) cannot be published until the operator pulls | P4c (a new branch is created at the commission's base, which the folder holds once fetched) | -| F53 | loop | A commission that exhausts its turns on a large change ends as a failure with no result file although its work is on disk; the agent recovers only by continuing the session | P4c (a run out of turns hands back its diff as a partial result) | -| F54 | executor | The collected diff includes the run's own `.aflow/` scratch when the agent writes there, so a coding agent's working copy of its patch was committed and published as part of the change | P4c (the run's scratch is never part of its diff) | -| F55 | loop | The repository's own gate (`yarn typecheck` with its guards: large files, context budget, lifecycle exhaustiveness) runs only in CI after the push; the coding agent checks with `tsc` per workspace and the review's sandbox cannot run `yarn`, so a guard failure is learned one push and one CI cycle late | P4c (the commission runs the guard scripts as checks; Local Publish runs the project's gate in the checkout before the approval) | -| F56 | executor | The ref guard is a hook the run's git is told about, so a git call that names its own `core.hooksPath` runs without it, a local push into the folder starts a receive-pack that never sees it, and a layout whose refs are symlinks into the folder's `.git` reads as another repository; the hook guards against accidents, not against a call that goes around it, and the before-and-after ref snapshot is the record — a net difference, so a ref moved and restored within the run leaves no trace in it | P4e (the run works in a private clone of the folder whose refs are nobody else's; the publication reads from it) | -| F57 | mcp | `start_session` never returned for a session that paused at its prompt, was woken by a run's wakeup and paused again; the MCP's watcher knows a first pause, not a session that speaks again | P4e (the watcher returns at the first pause and the wakeup reaches the caller as an event) | -| F58 | runner | The dev profile watches the product package but not the packages it imports from their compiled output (`run-view`), so a change that crosses both leaves the product's watch emitting against stale types and the web app failing on a module it cannot find until someone builds by hand | P4e (the profile watches every package the web app reads from `dist`, or the product's watch builds its references first) | -| F59 | loop | A publication superseded by a newer one of the same branch keeps its approval live and approvable, and an operator approved a stale one three times in a day; a blocking start also parks Helmsman on it, so every fix round began with a cancel | P4e (a newer publication of a branch withdraws the older one's approval; Helmsman starts publications with `wait: 'none'`) | -| F60 | op | The push grant is minted for any approve-intent human task whose approved call parses as a host push, from the call the client sent rather than the task's own preview, and it is written before the resume commits and never withdrawn when the claim is lost, so an approval that never landed still clears a later agent-driven approval of the same task | P4e (the grant is minted from the server-resolved preview of `approve-push` alone, after the resume commits) | -| F61 | op | A clean receipt clears the gate whatever the folder's posture, so under `always` a skill holding `host.process.exec` pushes a clean range without the approval the posture promised; and an approval given by an agent rather than the operator records `approved` while the push then fails unapproved on its single attempt, with no way to ask again | P4e (the gate reads the posture and requires the grant under `always`; a push refused for want of a grant asks instead of failing) | -| F62 | skill | A publication given no summary opens its pull request with an empty body, although the commit it pushed carries a message that says what changed and why | P4e (Local Publish takes the commit message's body as the pull request's where no summary is given) | -| F63 | loop | The review a publication runs before an unasked push is pinned at `depth: standard`, which runs none of the project's checks, so the first autonomous push went out on a reading of the diff alone and the checks were run by hand beside it — type-check, tests, lint, format and both CI guards | P4e (D16: the folder's declared checks run on the commit before the scan, and the push requires the receipt of their passing — a receipt, not a task the skill orders) | -| F64 | skill | A publication onto a branch that `main` has moved past reviews `origin/main..` as a two-dot diff, so the review reads every change `main` took in the meantime as a revert the branch makes, and the reviewer had to reason its way past an apparent undoing of a plan section | P4e (the review diffs from the merge base, `origin/main...`, while the scan keeps the commit range) | -| F65 | skill | A publication appended onto a branch that already has a pull request pushes and then fails at the pull-request step on GitHub's 422, so the run ends `failed` although everything it set out to do is done, and the pull request that already covers the branch is neither found nor reported | P4e (the publication asks GitHub for the branch's open pull request into the base before opening one, and reports the one it finds) | -| F66 | loop | A fix appended onto a branch that `main` has moved past carries none of `main`'s changes, and the loop has no step that merges them, so the branch learns it conflicts with its base only from GitHub after the push — a publication's commit and `main`'s both edited the same paragraph, and the merge was made by hand | P4e (D15: the commission merges `origin/` and its agent resolves the conflicts; the publication re-makes that merge and folds the patch into it — one merge commit) | -| F67 | executor | A publication whose whole change is the merge — a clean catch-up of `origin/`, or a turn that left every deletion the merge offered standing — is refused as having changed nothing; and a commission given `mergeFrom` without `base` merges onto the folder's HEAD into a patch no publication can take, with nothing refusing it before the turn | P4e (with `commit.mergeFrom` an empty patch lands the merge commit alone; `mergeFrom` without `base` is refused before any checkout) | -| F68 | executor | A Helmsman conversation that had driven work for three days failed at its 83rd turn with `conversation_history_hydration_failed`: a committed history batch could no longer be read, because conversation state is stored through the payload store like any step payload and the Redis store gives every payload its default 24-hour TTL; the error named the refs but not when each was committed, so an expired batch read the same as a corrupt one | P4e (committed conversation state is stored without a TTL; a hydration failure names each unreadable batch, the turn it was committed at and why) | -| F69 | executor | A check at another commit fails its type-check: a checkout mirrors the folder's installation but builds nothing, and every package type-checks against the others' compiled declarations, which `yarn install` writes through postinstall and a checkout never has. The mirror already resolved workspace links into the checkout; it was not the cause | P4e (the check first builds what the touched workspaces reference or import, in the checkout; workspace links are relative, so every path to the checkout resolves alike) | -| F70 | skill | A publication that fails after its commit leaves the branch it created on the machine, and a second publication of the same patch cannot reuse it — the branch exists and its head is not the patch's base — so the branch was deleted by hand before the retry | P4e (a publication whose commit is in place and whose later task failed resumes from that commit, or removes the branch it made when it fails) | -| F71 | platform | `persist` on committed conversation state covers the conversation store and compaction, not the two other writers of those kinds — human-task hydration stored as `state`, the ai-client's `history` — so the rule is stated where it does not hold; and a Redis payload store that never expires conversation state keeps every superseded per-turn state with nothing that removes it | P4e (every writer of a durable kind persists, guarded by a test over the kinds; removal of superseded conversation state at the terminal flush is deferred, not built) | -| F72 | executor | A restart of the host executor ends every process it started and discards every session's checkout, so a merge into the stack's checkout ends every commission, check and review in flight, and `tsx watch` kills five seconds after its signal | P4e (D17: the host executor drains on SIGUSR2, which only the dev stack's watcher sends, and waits for the exit; SIGTERM still ends everything) | -| F73 | executor | A checkout takes its third-party dependencies from the folder's installation, and that installation lags the lockfile whenever the operator has not run `yarn install` since pulling — the first real check on a merged tree failed on `playwright-core`, a dependency the lockfile had carried for a day and the folder had never installed | P4e (the check compares the folder's installation state with the checkout's lockfile and refuses by name when they differ, before building anything; the folder's `yarn install` stays the operator's) | -| F74 | executor | A drained restart waits for the steps in flight but not for a session between its turns: on exit it discards every kept session's checkout, so a merge between the turns of a continued commission ends that session and whatever its coding agent left uncommitted | Deferred, not built (a kept session that survives a restart, its checkout left on disk and taken up again by the next executor; until then a stream continuing a session merges only once that commission has published) | -| F75 | workbench | The workbench's recent conversations do not say whether a conversation is active — Helmsman mid-turn, or a commission or publication it started still running — so the operator opens each one to find out | P2 (a conversation's row carries its live state: the agent's turn, the runs it waits on and their phase, from the session hot state and the attention list) — delivered: the session list reads each status from the hot state, the projection emits `entity.session.status` when a turn begins or rests, and the row joins the Workbench's active-run feed; no request per row | -| F76 | loop | A commission that runs past `host.harness.run`'s default timeout is killed mid-turn with its work on disk and no result, and the conversation that started it reported the kill as a 27-second off-script run that created another stream's branch — an account that matched nothing in the run's own activity log | P4e (a killed commission returns `killed: { afterMs, byTimeout }` beside its patch so the next brief continues it; a result the agent cannot explain is reported as the data it carries, never as a story) | -| F77 | op | A commission's `maxTurns` is a number the briefing agent picks, and a brief that asked for a generous budget got 40 — a slice of this size spends that reading the code — so the run ended before its first edit and the result could only say so; a cap sized to a guess is the length-cap mistake again | P4e (the operation's default turn budget is sized for the work it exists to run, several hundred, named with its reason; a run that reaches the cap says `turnCapReached` as data beside its patch) | -| F78 | readiness | A model provider refusing for credit shows in the conversation that met it, with the error and a retry that works, and nowhere else: another stream, or a driver reading run and session state, learns of it only by trying that provider again | P2 (the space's readiness carries the provider's last refusal beside the missing-key line, cleared by the next accepted call, so every conversation and every watcher sees it once) | -| F79 | skill | A publication that waits for an approval measures `origin/` at its scan and pushes much later, so a base that moved while the operator was away makes the push gate refuse the receipt as covering another range — the gate is right, and the run has no way to scan the range it now sends and push again; the branch stays on the machine with an approved commit | P4e (a push refused for a moved base re-measures, re-scans the new range and, where the posture requires it, re-reviews before it pushes again, inside the same run) | -| F80 | executor | The folder's check runs the test files a commit touches, so a schema that gained a required field passed the check while a fixture in an untouched test file elsewhere no longer parsed — the review caught it by reading, and CI would have; the check did not | P4e (the check also runs the tests of every workspace that depends on a touched package, bounded by the same budget, so a contract change meets its consumers before the push) | -| F81 | executor | A check's stored output is cut at a size from its start, and a test sweep's failure is at its end, so the first check that failed in the dependents' sweep stored 88 lines of passing tests and no failing one; the conversation that started it could only say that something after the visible tail failed | P4e (the stored output keeps its head and its tail with the cut marked between them, and the check's test step prints failures and the summary rather than every passing file) | -| F82 | executor | The first dependents' sweep ran 842 test files under the check's sandbox and failed eleven that pass on `main`: ten start a local server and time out at exactly their five-second limit, so the sandbox refuses loopback, and one is a pid-timing test run among 842 files on a laptop that also runs the stack | P4e (the sweep runs the test files whose imports reach a touched file, not every dependent's suite, with vitest's workers bounded; a test that serves itself declares the `listener` tag, runs under the check on Linux in the sandbox's own namespace, and on macOS, where the sandbox gives it no loopback, is left out by name, counted in the receipt, and run in CI (D19)) | -| F83 | executor | A commission cannot add a dependency: its checkout mirrors the folder's installation and the sandbox reaches no registry, so a change that needs a package was finished by hand or by the agent editing `yarn.lock` directly, and the check then runs against an installation that lacks it (the Plan 320 stream's 320-F2) | P4e — under `open` (D19), the registry is reachable, so the package manager writes the lockfile; no commission writes the folder's installation under either posture, so a changed lockfile is published with an install step the operator runs once, named in its result, and the check refuses it until then | -| F84 | executor | Inside a commission every shell command exits 1 with a sandbox refusal about a temporary working-directory file even when the command succeeded, so the agent's own checks all read as failures and its reports of them cannot be trusted; the run's activity log shows every tool result `ok: false` for weeks (320-F26) | P4e — retired under `open` (D19), whose shell writes its file in the job's own temporary directory, which `CLAUDE_CODE_TMPDIR` names, measured by the lane's boot self-test, where a shell command must exit 0; `confined` hands it the same directory | -| F85 | executor | `yarn` is unavailable inside a commission because Corepack fetches the pinned Yarn over a network the sandbox closes, so the agent cannot run the repository's test command and improvises (320-F3) | P4e — retired under `open` (D19), whose sandbox reaches the hosts Corepack fetches the pinned Yarn from, measured by the boot self-test, where `yarn --version` must run; remains the confined posture's: the lane provides the pinned Yarn from the folder's installation or a cached release | -| F86 | executor | The sandbox refuses writes to agent configuration at the repository root (`.mcp.json`), which has to be added by hand (320-F28) | P4e — not retired by `open` (D19): the sandbox protects agent configuration files under both postures; the policy names the agent-configuration files a commission may write, or the lane writes them from the harness profile | -| F87 | skill | The secret scan stops on ordinary code — `?token=abc123` in a test URL, a CSS selector held in a constant whose name contains `password` — and its remedy, recommissioning on a fresh branch, repeats the whole commission; the local commit holding the line was amended by hand (320-F12, 320-F25) | P4e (the rules exempt test fixtures and selector-shaped values by evidence, and a finding in a local unpushed commit is cleared by removing that commit from the branch and publishing the corrected patch, inside the loop) | -| F88 | op | `host.commit.scan` reports `PROJECTION_FAILED` on `unflaggedRange` and `receipt` beside a real finding, because a scan that found something has no receipt and the projection expects one (320-F13) | P4e (the projection declares which outputs a finding leaves absent, so the failure carries the finding alone) | -| F89 | loop | Two streams take the same tenant migration number: a commission takes it from `origin/main` at start and the collision shows only as a conflict at GitHub after the push (320-F19) | P4e (the migration index is checked at publication against `origin/` as the push is measured, and a taken number fails the publication by name before the push) | -| F90 | loop | Each review pass finds new issues in code an earlier pass already read, so a publication is a loop of unknown length and every turn of it is a full commission (320-F15) | P4e (a review's findings are recorded on the pull request as the handoff Plan 321 uses, so a later pass reads what the earlier one judged and says what changed; depth `deep` runs the checks and is the default for a branch's first review) | -| F91 | mcp | A session that fails on its model provider is opaque through MCP: `inspect_session` and `watch_session` show `FAILED` with every step unknown and no error, and offer no retry, while the web shows the provider's error and a retry; the cause was readable only from the step's stored error payload (320-F23, 320-F29) | P2 (the MCP's session read carries the failed step's error and the retry the web offers; the readiness line of F78 says it once for every surface) | -| F92 | op | Helmsman's first read of a harness result guesses a field that is not there (`/data`), spending one step per commission on a `MEMORY_NOT_FOUND` that lists the real fields (320-F5) | P4e (the result's shape is in the operation's output description and the commission skill's result guidance, so the first read names a field that exists) | -| F93 | platform | No check exercises an operation against the capability profile a real local space holds (Personal Safe), so a capability-migration gap shows only on the first live call (320-F17) | P4e (a contract test holds every registered operation's capability against each system profile's allowed set and fails on an operation no local profile can call) | -| F94 | loop | A cancelled publication wakes the conversation that started it, and Helmsman answered the wakeup by commissioning a fix round nobody asked for, from the review findings it had — thirty minutes of coding agent on a shape the operator had not chosen | P4e (a cancellation's wakeup says who cancelled and that nothing is asked; a commission is started only from a message in the conversation) | -| F95 | workbench | A commission started as a run shows, in the workbench's run view, a coding-agent task that is `running` with `0ms` and "has not reported yet" for its whole half hour: the harness's live feed reaches the chat card of a direct call and no surface of a run, so the operator cannot tell a working commission from a stuck one | P2 (the run view's harness task shows the same live feed the chat card shows — the agent's last tool line, elapsed time, turns — from the step's activity stream) — delivered: a run's live-delta wake names its task steps, so a view opened mid-task reads their buffers; the run container folds the frames and mounts `HarnessStepCard`, the chat's card, timed from the task's start | -| F96 | loop | Helmsman's link to a commission run is a `sessions/` address, which the web answers with "Failed to fetch run"; the run lives under `runs/` | P2 (the link a run result carries is built from the run's own route, by the platform, not composed by the agent) | -| F97 | workbench | The harness activity card is a scroll of tool invocations and their exit codes, unreadable in real time and misleading while F84 marks every command a failure; what an operator watching a commission needs is proof of life, what the agent says it is doing, any plain-text message it writes, and the files it is changing | P2 (the card leads with the agent's last status or thought and the time since the last activity, then the files touched, with the tool lines collapsed behind a disclosure; exit codes shown only once F84 makes them true) — delivered: the activity line carries `intent`, a call's `changes` and a result's `command`; a command's result shows no verdict until F84 is retired | -| F98 | executor | Under `open` the sandbox opened the machine's whole loopback to every coding agent and check, and the stack's Redis listens on 6379 with no password and holds the `aflow:write-approval:*` grants the push gate reads, so code an agent wrote could mint a grant and push past the gate | P4e (D19: no port list — the machine's loopback is never a job's under either posture; the `open` launcher refuses this machine on every port, and the job's loopback is the namespace's own on Linux and none on macOS); the second half is P4e (D20): every service of the stack requires a credential, Redis the machine's password, which `REDIS_URL` is given | -| F99 | skill | A loop pull request's title and body are the last publication's title and a one-paragraph summary written in the brief, so a branch of eight commits is titled by its final fix and its body says nothing a reader can act on; a pull request written by hand (#64) carries what changed as a table where one fits, the decisions as bullets, the known gaps, and what was verified and what was not | P4e (Local Publish composes the pull request from the branch as a whole: the slice's title, what changes from every commit's message, the review's findings closed and left open, the check's results as verification, and the gaps the commissions recorded; on an appended push it rewrites the body rather than leaving the first one) | -| F100 | loop | A publication's `patchRef` and `baseSha` are retyped by Helmsman from a commission's result, and two publications of one patch failed at the commit on a sha with one wrong character and then on the wrong base, while the result the commission run holds was right both times | P4e (Local Publish takes the commission run's id and reads `patchRef`, `baseSha` and `merge.from` from that run's result itself; a brief names a run, not shas) | -| F101 | platform | Denying the stack's own ports under `open` cannot keep up with what listens on the machine: the stack's MCP server on 3100 hands the owner's API key to any local session that brings no credential, and `infra:tools` publishes Redis Commander and pgAdmin without a login, so a job that reaches loopback acts on the stack as the owner however many ports a list names | P4e (loopback under `open` is the job's own — the Linux namespace's, or on macOS none — and never the machine's; a test that needs a listener of its own runs in CI and is counted as skipped in the check's output; every local service of the stack requires a credential, F98's second half, as the independent layer) | -| F102 | skill | A publication that has pushed fails on a GitHub request that times out at the pull-request step, so the branch is on `origin` with no pull request and the run reads `failed` for a change that is published; the step was retried by hand through Helmsman once GitHub answered | P4e (the pull-request steps retry a timed-out request within the run, and a publication whose push succeeded ends `completed` with the pull request's absence named rather than `failed`) | -| F103 | platform | A Helmsman conversation re-sends its whole history every turn, and compaction keys on the model's context window, so on a million-token model nothing is ever compacted and a conversation that drives a day's rounds pays for ninety thousand tokens of its own past on each of dozens of turns — thirty dollars a day on one conversation, and the provider's monthly limit reached in a day | P4e (compaction keys on cost and age as well as the window: a turn's history is folded once its re-read cost crosses an operator knob, old tool results and superseded briefs first, and a conversation's running spend is on the space beside the model) | -| F104 | executor | The sandbox denies writes to `.mcp.json` in the checkout (`operation not permitted`), the coding agent's edit of it failed twice, and both times the commit's title and the plan text claimed the change — a refused write surfaces as one line among fifty tool results, and nothing in the result says a file the task named could not be written; the change was made by hand on the branch | P4e (a write the sandbox refuses inside the checkout is reported on the result as `refusedWrites`, and a task that names a file the policy denies is refused before the turn; F86's measurement: the denial is the sandbox's own protection of agent-configuration files) | -| F105 | platform | The Redis integration suites probe `127.0.0.1:6379` with no credential and skip themselves when the probe fails, so the moment the dev Redis gained a password every one of them stopped running locally with no failure anywhere, while CI's unprotected Redis still ran them — a protected service made the tests disappear rather than fail | P4e (one shared probe reads the stack's own `REDIS_URL`; no Redis skips, a Redis that refuses the credential fails the suite by name) | -| F106 | platform | The Redis password was generated into each checkout's `.env` while every checkout on the machine shares one Redis container, so a second worktree's first `yarn start` wrote a password the running Redis never had and every service in it failed with WRONGPASS, and its `infra:up` would have recreated the container and broken every other checkout; the readiness check read a NOAUTH reply as "protected" without ever trying the password it carried | P4e (a credential for a shared container lives in one machine-level file the stack reads and every checkout's `.env` derives from; readiness authenticates with the password it holds; a second checkout adopts the machine's credential instead of minting one) | -| F107 | skill | The commission brief lists the agent's checks by hand (`npx eslint` on "touched sources"), the agent linted the TypeScript it touched and not the `.mjs` test it added, and the folder's check refused the publication on that one error — a round of the loop (commission, commit, build, check: about twenty-five minutes) spent on what the folder's own check would have told the agent in its checkout, because the agent runs a list retyped from the protocol while the folder declares the check that decides | P4e (Commission Change carries the folder's declared check into the agent's task as the proof it must run from its checkout, `AFLOW_CHECK_BASE...AFLOW_CHECK_SHA` set as a publication sets them, so the brief names no checks and the agent cannot run a narrower list than the gate) | -| F108 | skill | The folder's check selects tests by import reachability from the touched files, so the two guards in `packages/schemas/src/edition/mcpRegistration.test.ts` that read the root `.mcp.json` never ran when the branch changed it; five publications passed the check and the push, CI failed on the guards, and the failure was found by hand in the morning — a test that reads a repository file is reached by no import, and a file outside every workspace is reached by no test the check knows; the round that taught the check to run the edition guards for such a file was itself missed the same way — `workspaceLinks.test.ts` runs `scripts/verify-commit.mjs` by path over a fixture repository, no import reaches it, and CI found the guard directory read unconditionally where the fixture has none | P4e (a touched file outside every workspace runs the repository-shape guards — the tests under `packages/schemas/src/edition` are what read such files — and the publication's report names them as run for that file; a test whose source names a touched file's path is reached by it, import or not, so a script run by a test meets that test before the push; the CI failures themselves are Plan 321's case: nothing on the platform noticed them) | -| F109 | platform | The laptop slept through the night with dark wakes every quarter hour; a brief posted in one wake window was consumed by the orchestrator at the next, after the executors' sixty-second heartbeats had expired in the sleep between and before they refreshed, so the enqueue refused the commission ("No executor available for step type: host") and then Helmsman's own next turn ("… ai") — an outage classified `transient` and `retryable` ended the workflow run and the conversation terminally, and the next round needed a new conversation | P4e (three parts, in cost order: the host executor holds a sleep assertion while a harness run, check or review is in flight — `caffeinate` on macOS, `systemd-inhibit` on Linux — released when idle, under a pairing knob `aflow harness keep-awake on-ac | never | always` defaulting to on AC; an enqueue refused for a missing executor parks the step and retries for a bounded time instead of failing it, the session's failure record keeps the step's own classification, and an executor refreshes its heartbeat on wake before the orchestrator's consumers resume; and a cold restart measured — a commission started, the stack killed and brought back, what each piece did recorded — so a retry continues the coding agent's own session (`continueFrom`) rather than restarting the brief; a sleeping or unplugged appliance is the local edition's normal night) | -| F110 | executor | While writing its result, a review's shell quoting error ran `yarn start` and executed `.env.example` inside the folder's checkout; under the `open` posture both failed at once — the Docker socket out of reach, tsx refused its IPC socket — and the checkout showed no change but `.aflow/`: the posture held where a review that runs no checks by contract had every means to start the stack by accident | P4e (none: the measurement D19 asked for — the sandbox, not the review's discipline, is what kept the stack's start from running; recorded as the evidence) | -| F111 | skill | With a second stream's commission on the machine, the folder's check took 570 s and failed on one test the branch never touched — `saveResponse.test.ts` writing an 11 MB body timed out at Vitest's 10 s default — so a passing commit was refused for load; Helmsman retried the check on the same commit through `workflow.run.resume` on its own and it passed | P4e (the check's test run on a shared machine sets a per-test timeout sized for load, one constant, and a test that measures a body's size declares its own; a check that fails on a timeout alone is retried once by the publication itself before the operator hears of it, and the retry is recorded on the task) | -| F112 | skill | Every publication builds the touched workspaces' whole reference closure cold in its detached checkout — about twenty packages for an orchestrator change — so a check takes five to nine minutes of which lint is seven seconds and the tests two; the operator read the wait as the lint step | P4e (the check's live output names the step it is on, so the run view says "building @aflow/schemas" rather than "running"; the cold build stays, since no key for a cache across checks holds against the check that made the build) | -| F113 | skill | `main` moved while a publication ran (two other pull requests merged between its scan and its push), so the push gate refused the receipt — the scan covered the range from the old `main`, the push would send the range from the new one — and a reviewed, approved commit sat unpushed with the remedy addressed to a person: "scan the range the push sends"; the round was repeated with `mergeFrom: origin/main` by hand | P4e (the publication computes the range it will push at push time and, where `origin/main` moved since the scan, scans that range itself and pushes with the fresh receipt — one more scan, no round; the same re-scan covers a receipt the host executor no longer recognises because it restarted between the scan and the push, which failed a publication the same way after the stack restart of F116; where the move conflicts, it reports the merge round as the remedy by name) | -| F114 | process | The plan document is the one file every stream writes — each slice its D-text and delivery note, each docs round its finding rows — so `main` moving under a pull request conflicts there and nowhere else: #68 once, #72 twice in one afternoon, each costing a merge round of the loop (commission, check, scan, review, push: about twenty minutes) to resolve a conflict with no content in it | P4e (the findings table moves to its own append-only file beside the plan, `315-findings.md`, that no slice edits and every docs round appends to, so a D-text and a finding row never meet; the publication, meeting a conflict whose every hunk is append-only, resolves it itself — F113's re-scan then carries the merge too) | -| F115 | platform | With two coding agents, a check and a browser session on the machine, a backend of the development Postgres exited with code 2 and the postmaster restarted every backend — ten seconds of fsync, automatic recovery, nothing lost; the stack logged 137 refused connections ("the database system is in recovery mode") in one second, every service reconnected, ten heartbeats held, and the publication and review in flight continued; the Postgres log names no client, so the cause is unknown | P4e (none yet: the stack rode through a database crash as designed — recorded as the evidence; if it recurs, `log_connections` and the client's `application_name` on every pool are the first measurement, since every connection today is `phoenix-unknown`) | -| F116 | platform | The database restart (F115) killed the orchestrator: the Postgres client threw `Cannot read properties of null (reading 'write')` from `connection.js nextWrite` on the dropped connection, the process exited, and `tsx watch` — which restarts on a file change and not on a crash — left it dead; the stack's ports stayed up, the web app answered, ten executor heartbeats held, and for forty minutes nothing consumed the control, result or timer streams: a review's result was never applied, three Helmsman messages were accepted and never ran, a `workflow.run.cancel` stalled, and every stream on the machine stopped without a single signal that the orchestrator was gone | P4e (the orchestrator survives a dropped database connection — the client's abrupt-close path is caught and the pool reconnects, with a test that kills the connection under a write; the dev runner restarts a service whose process exits, as the appliance's supervisor does; the orchestrator publishes a heartbeat of its own and `yarn start`'s health, the MCP `auth_status` and the web's status line say when no orchestrator is consuming) | -| F117 | skill | A publication that committed, checked, scanned and was approved, then had its push refused, strands its commit: the push cannot be retried because the receipt is baked into the task's input, a new publication cannot append because the branch has moved to the commit it would push, the agent cannot re-scan because `host.commit.scan` is the publication machinery's and not the Helmsman's, and the only lever it could name was a token change to give the machinery something to commit — the merge commit `3345f45` on the F109 branch sat reviewed and unpushed | P4e (a publication of a branch as it stands — no patch — runs check, scan, review and push on the branch head, so a reviewed commit the machinery already made is never stranded; with F113's re-scan at push, a refused push repeats its scan and pushes instead of ending the run) | -| F118 | platform | After a merge that added an export to `@aflow/schemas`, `yarn start` printed "Stale dist/ in 8 package(s) — safe to ignore in dev (ts-source resolves to src/ directly)" and the web app failed on its first page: the Next application resolves packages through `dist`, so the stale build the message dismissed was the one the browser loaded (`Export browserApprovalActionPhrase doesn't exist in target module`), while every Node service, on `ts-source`, was fine; the operator saw the error, the stack reported health | P4e (the start rebuilds every stale package the web application reads before it serves it, or runs them in watch as it does `@aflow/web-product`, and the stale-dist line names the packages and says which consumer reads `dist`; a dev health check requests the web app's first page and fails loudly on a module error) | -| F119 | skill | A review that reached its verdict and lost it writing the result — the harness's last call met the provider's 429 — failed the publication's review task with no verdict, and the publication paused at `approve-push` with its commit already on the branch; the remedy taken was to cancel it, which stranded the commit (F117), and the right one — resuming the publication with the failed review task retried — was named by no surface | P4e (a review that fails with no verdict is retried once by the publication itself, as the check is on a timeout (F111), and a publication paused at `approve-push` after a failed review names the retry as its remedy) | -| F120 | skill | A cold Helmsman conversation that continued the stream from its plan node commissioned correctly but with none of the loop's standing inputs — no `wait: 'none'`, no model, no base, no turn budget — because those live in the protocol document and in the operator's briefs, not in the skill; the run waited on its commission with the conversation blocked, as every early round did before D13 | P4e (Commission Change's defaults are the protocol's: `wait: 'none'`, `base: origin/main`, the folder's default model, the two-hour timeout and the several-hundred-turn budget, each overridable; a brief names an input only to change it) | -| F121 | executor | The fix round's commission named the F116 commission's kept session (`continueFrom`) and was refused as expired or lost to a restart — neither had happened: a kept session is owned by the run that made it and every commission is a new run, so from a conversation a commission can never continue another's session, while the protocol page says a stream continues one | P4e (a kept session is owned by the conversation that started the run that made it, which the orchestrator stamps on the run's operation-task jobs as `startingConversationId`, so a fix round from that conversation continues the coding agent's session; a run no conversation started owns its own; a retry continues only its run's session; another conversation's session is refused as a missing one is, stating the conversation rule and how a kept session ends) | -| F122 | skill | Before the fix round's commission took, Helmsman put `wait` among the task's inputs and then `timeoutMs` at the run level, each refused with a teaching error, two turns spent; and with the commission running it asked whether to publish when it landed, a question the brief had answered, holding the round until a person chose | F120 (an input a brief need not name cannot be misplaced); the skill's step after a commission's end is the publication, not a question | -| F123 | executor | The F113/F117 slice's own publication was refused at the push twice over: `main` moved during its run, the scan receipt was refused for the range (F113), and when the operator re-scanned the pushed range by hand the check receipt was refused next, because it binds to the base as the scan's does and a re-check against the moved base is the folder's forty minutes; both refusals name their remedy — `host.commit.scan`, `host.commit.check` — to the agent that reads them, and that agent is refused their promotion (`out_of_scope`), so the commit went up with a plain `git push` from the folder | P4e (the publication re-scans and re-checks at push time itself, or the ops are promotable to the agent that reads the refusal, or the refusal names a remedy that reader has; tests cover the wording of both refusals) | -| F124 | executor | A merge round's publication scanned `main..head` and read the merge commit against each of its parents, so `main`'s own content — a binary already on `main` — counted as added and unread, the scan was unclean, the review was skipped and the push asked the operator; every later publication of that branch meets it again, because the merge commit stays in the range | P4e (the scan reads what the range adds relative to the push base, a commit's own diff against its first parent and never a merge's second-parent diff; a receipt's `unscanned` names only files the range itself adds) | -| F125 | skill | PR #94's CI failed one tree-walking guard (`backgroundWork.test.ts`, which took the git secret scan's `history.scan(` for a Redis keyspace scan) that the folder's check never ran: the guard lives in `@aflow/schemas` and is import-reached by nothing the slice touched, the third miss of this shape after F108's two | P4e (the check runs every tree-walking guard whenever a production source is touched — delivered for the guards that read the background-work scanner (#94); the durable-payload guard and any other tree-walker that reaches no shared module are still selected by import alone) | -| F126 | process | Four times in five days a required check failed on the open slice for reasons outside it: the dependency audit runs only in pull requests, so an advisory published against a transitive package is first met by whichever slice is open, each wave needed a pin pull request of its own (#96, #97, #106), and a rerun of the failed job tests the merge commit the run was created with, never the moved `main` | Plan 321 (the audit runs on `main` on its own schedule and a pin round opens its own pull request, so a slice's CI answers only for the slice; a slice whose base moved is retested by a branch update, not a rerun) | -| F127 | realtime | Opening the chat page's Workbench panel fired the conversation list, the presence lookup and the active-runs query in lockstep at about a hundred requests a second until the read limit refused everything, the Store's requests with them: the panel's first subscription to the space's entity events names no cursor, the gateway read that as `'0'` and drained the whole stream — Full Circle's 2,149 events, 1,065 `entity.session.described` and 1,024 `entity.run.updated` — and two hooks refetched on every one, each refetch paced by the server's response time; pinning TanStack Query and Next back changed nothing | P4e (a subscription naming no cursor starts at the stream's live tail and one naming a cursor resumes from it; the active-runs feed and the conversation list refetch once per burst of events, not once per event; `entity.session.described` is emitted when the job changes a title or summary and when a rename changes any field a window holds, revision included; a reconnect before any event resumes from the subscription's acknowledged cursor; a test covers each) | +| # | Surface | Finding | Phase | +| ---- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| F1 | rail | Collapsed rail names nothing; expanding takes a click and pushes content; no accessible names when collapsed | P0 (D9) | +| F2 | chat | Every fresh chat shows "Reconnecting…" although the socket is live; the notice reads a session subscription that does not exist yet | P0 (D6) | +| F3 | chat | Empty-state copy is the hosted product's; nothing about this computer | P2, P3 | +| F4 | rail | "Shop" in the rail, "Store" everywhere else | P0 | +| F5 | web | Report-only CSP `connect-src 'self'` omits the realtime origin | P0 | +| F6 | onboarding | "Workspace ready — ready to run agents" is the only post-create step | P3 | +| F7 | computer | The connect command assumes a source checkout | P1 (D5) | +| F8 | computer | No machine state: paired, executor, coding agent, last seen | P1 | +| F9 | chat | Helmsman's first answer links nothing, says "coding harness", asks for push credentials the local push does not use | P0 copy, P3 data | +| F10 | store | No local-first shelf; listing copy says "harness" | P0 copy, P3 shelf | +| F11 | skills | "Describe one to the cybernetic agent" | P0 | +| F12 | integrations | Repos tab and code-repository option for an absent lane | P0 (D3) | +| F13 | settings | Hosted-only tabs shown; credentials buried; tabs unreviewed | P0 gating, P1 credentials, P2 review | +| F14 | workbench | Nothing about this computer | P1 | +| F15 | chat | Opening a space's chat always starts empty | P2, P3 | +| F16 | chat | The first answer ran a memory query before a how-do-I-start question | P3 (state, not a query) | +| F17 | perf | 644 MB heap for one chat tab in dev | P5 | +| F18 | memory | Embeddings ride a platform credential or fail silently; no per-space provider or model; a switch has no re-index | P3 (D12) | +| F19 | operator | Approvals and questions have no place of their own beside the chat | P2 (D11) | +| F20 | mcp | The MCP dev server needs a hand-made auth file and an `.env` line; the stack already provisions the instance credential it wants | P3 (the stack hands it over) | +| F21 | runner | A second stack started before the first has stopped is killed by the first's port sweep, and `tsx watch` then holds a dead server | P4b (runner refuses a held port) | +| F22 | runner | Boot warns about the dev Redis password and the Postgres connection budget; each is one configuration talking to another's default | P2 review | +| F23 | loop | The first commission on a fresh repository went straight to a well-formed `host.harness.run`; the D10 baseline | §3 measure | +| F24 | loop | A Helmsman waiting on a child run takes no parallel request; the review of a paused publication has to be started by hand | P4b (the publication reviews itself) | +| F25 | loop | One `workflow.run.start` produced two run records and a refusal naming a run the same call had made | P4b | +| F26 | realtime | A tab on `127.0.0.1` was refused the socket; the allowlist named one loopback spelling | delivered (#14) | +| F27 | loop | A kept coding-agent session does not survive an executor restart; every restart drops the analysis a continued commission reuses | P4b (the session record beside the run) | +| F28 | store | A space runs the installed projection of a platform skill; a registry that moved leaves the loop refusing an input the skill has | P3 (platform bundles follow the registry) | +| F29 | op | `host.harness.run` checks `applies` against the folder's tree, so a patch made from a named base reads as a conflict | P4b | +| F30 | store | No update operation for an installed listing; the REST route wants `mode` and a uuid key and its 400 names no field | P3 | +| F31 | runner | Ctrl-C returns the prompt before the runner has finished; its last lines land after it and read as a hang, and `yarn kill` follows | P4b (`start.mjs` waits) | +| F32 | runner | A runner that yields the host lane to an exiting executor never starts its own | P4b | +| F33 | mcp | `start_session` with a platform role and a wrong argument name makes a session that fails on an empty prompt instead of refusing the call | P3 (the MCP refuses at its schema) | +| F34 | loop | A publication waiting on its push approval holds the workflow's single active-run slot, so no other change can be published in the space until it is decided | P4b (the skill declares its concurrency) | +| F35 | action center | The push approval shows the next task's raw input and not the pause prompt; nothing names the commit, its files or a review verdict, and the prompt itself still reads `..` unfilled, so the operator approves blind | P4b (the card renders the prompt and the commit) | +| F36 | loop | A publication whose pull-request call fails after the push cannot re-run that call once the cause is fixed; the pause offers only a hand-written output or failure | P4b (re-execute after the operator's fix) | +| F37 | skill | Local Publish takes `owner` and `repo` as free strings; a repository given as `github.com/aflowai/aflow` reached GitHub as a path and answered 404 after the push | P4b (one `repository` input, normalized from a name or URL) | +| F38 | skill | The commit step's refusal names `base`, the op's field, while the skill's input for that sha is `baseSha` and its `base` is the pull request's target; two callers put the sha in `base` and read the refusal as confirmation | P4b (one name for one thing at both layers) | +| F39 | skill | A review raised a major it could not reproduce (the sandbox refuses a pty) from job-control reasoning, and a five-second test on the machine refuted it; the verdict turned on an inference | P4b (a finding says how it was established; only reproduced or read findings drive the verdict) | +| F40 | skill | A GitHub credential that cannot see the repository surfaces as a raw 404 after the push (the push itself rides the operator's own git); the publication learns of it one step from done, and nothing names the credential | P4b (read the repository before the approval; a 404 on a private repository says which credential) | +| F41 | executor | Every commission and review leaves its detached worktree registered in the operator's repository under the temp directory; ten of them after one evening, four already prunable | P4b (the run removes its worktree; boot prunes the stale ones) | +| F42 | loop | A commission with no base starts at the folder's HEAD, which is as fresh as the operator's last pull; a change made two merges behind main reaches the pull request behind it | P4c (a base may name the remote's branch, fetched first) | +| F43 | skill | A deep review cannot run the repository's tests: its sandbox copy of the branch has no built workspace packages, so vitest fails to resolve them and the new tests go unverified | P4c (the review checks out the range in a worktree of the folder, dependencies linked, as a commission does) | +| F44 | skill | The publication's pull request is authored by the operator's own token, so a ruleset requiring one approving review cannot be satisfied by the only reviewer there is | P4c (the pull request is opened as the space's app installation, the operator reviews it) | +| F45 | skill | The patch rides as an inline run input and `workflow.run.start` caps inputs at 32 KB, so a change over that size cannot be published through the skill at all; the commission's own output already carries it as a payload | P4c (`host.file.patch` takes a payload reference; the skill binds the commission's ref, never the bytes) | +| F46 | skill | A review's range resolves against the folder's local `main`, as fresh as the operator's last pull; a one-commit branch cut from the remote read as twenty commits, three of them merged upstream | P4c (the review reads the range against the remote's base, fetched first, as a commission may) | +| F47 | executor | The ref guard reaches every git a commission or review runs, so the executor's own tests, which commit in repositories they create, cannot run inside either; the guard belongs to the connected folder's repository only | P4c (the hook refuses only refs of the folder it guards) | +| F48 | orchestrator | A `$ref` to a prior output nested inside a tool argument (`inputs.patch`) replaces the whole argument with the referenced value, so a run cannot be started with one input taken by reference; Helmsman retried the same call until it gave up on references | P4c (a nested reference resolves in place; a test with a reference below the top level) | +| F49 | chat | The live coding-agent card the chat shows during a run stays pinned at the bottom after the run ends when the tab watched it live (a reload folds it), and two runs leave two pinned cards: the live card never receives its settled transition | P2 (the live card folds into the transcript at the run's end) | +| F50 | skill | An append onto a branch that already has a pull request still calls create, which GitHub refuses with 422; the run pauses one step from done and the agent supplies the existing number by hand | P4c (a publication reads the branch's open pull request first and updates it) | +| F51 | store | A skill entry's version can move without its bundle's, and the Store compares bundles, so a space installing or updating the bundle may keep the older skill; whether the projection follows the entry is unverified | P3 (the bundle version derives from its entries, or the Store compares entry versions) | +| F52 | op | A publication onto a fresh branch requires the commission's base to be the folder's HEAD, so a commission started from the remote (the answer to F42) cannot be published until the operator pulls | P4c (a new branch is created at the commission's base, which the folder holds once fetched) | +| F53 | loop | A commission that exhausts its turns on a large change ends as a failure with no result file although its work is on disk; the agent recovers only by continuing the session | P4c (a run out of turns hands back its diff as a partial result) | +| F54 | executor | The collected diff includes the run's own `.aflow/` scratch when the agent writes there, so a coding agent's working copy of its patch was committed and published as part of the change | P4c (the run's scratch is never part of its diff) | +| F55 | loop | The repository's own gate (`yarn typecheck` with its guards: large files, context budget, lifecycle exhaustiveness) runs only in CI after the push; the coding agent checks with `tsc` per workspace and the review's sandbox cannot run `yarn`, so a guard failure is learned one push and one CI cycle late | P4c (the commission runs the guard scripts as checks; Local Publish runs the project's gate in the checkout before the approval) | +| F56 | executor | The ref guard is a hook the run's git is told about, so a git call that names its own `core.hooksPath` runs without it, a local push into the folder starts a receive-pack that never sees it, and a layout whose refs are symlinks into the folder's `.git` reads as another repository; the hook guards against accidents, not against a call that goes around it, and the before-and-after ref snapshot is the record — a net difference, so a ref moved and restored within the run leaves no trace in it | P4e (the run works in a private clone of the folder whose refs are nobody else's; the publication reads from it) | +| F57 | mcp | `start_session` never returned for a session that paused at its prompt, was woken by a run's wakeup and paused again; the MCP's watcher knows a first pause, not a session that speaks again | P4e (the watcher returns at the first pause and the wakeup reaches the caller as an event) | +| F58 | runner | The dev profile watches the product package but not the packages it imports from their compiled output (`run-view`), so a change that crosses both leaves the product's watch emitting against stale types and the web app failing on a module it cannot find until someone builds by hand | P4e (the profile watches every package the web app reads from `dist`, or the product's watch builds its references first) | +| F59 | loop | A publication superseded by a newer one of the same branch keeps its approval live and approvable, and an operator approved a stale one three times in a day; a blocking start also parks Helmsman on it, so every fix round began with a cancel | P4e (a newer publication of a branch withdraws the older one's approval; Helmsman starts publications with `wait: 'none'`) | +| F60 | op | The push grant is minted for any approve-intent human task whose approved call parses as a host push, from the call the client sent rather than the task's own preview, and it is written before the resume commits and never withdrawn when the claim is lost, so an approval that never landed still clears a later agent-driven approval of the same task | P4e (the grant is minted from the server-resolved preview of `approve-push` alone, after the resume commits) | +| F61 | op | A clean receipt clears the gate whatever the folder's posture, so under `always` a skill holding `host.process.exec` pushes a clean range without the approval the posture promised; and an approval given by an agent rather than the operator records `approved` while the push then fails unapproved on its single attempt, with no way to ask again | P4e (the gate reads the posture and requires the grant under `always`; a push refused for want of a grant asks instead of failing) | +| F62 | skill | A publication given no summary opens its pull request with an empty body, although the commit it pushed carries a message that says what changed and why | P4e (Local Publish takes the commit message's body as the pull request's where no summary is given) | +| F63 | loop | The review a publication runs before an unasked push is pinned at `depth: standard`, which runs none of the project's checks, so the first autonomous push went out on a reading of the diff alone and the checks were run by hand beside it — type-check, tests, lint, format and both CI guards | P4e (D16: the folder's declared checks run on the commit before the scan, and the push requires the receipt of their passing — a receipt, not a task the skill orders) | +| F64 | skill | A publication onto a branch that `main` has moved past reviews `origin/main..` as a two-dot diff, so the review reads every change `main` took in the meantime as a revert the branch makes, and the reviewer had to reason its way past an apparent undoing of a plan section | P4e (the review diffs from the merge base, `origin/main...`, while the scan keeps the commit range) | +| F65 | skill | A publication appended onto a branch that already has a pull request pushes and then fails at the pull-request step on GitHub's 422, so the run ends `failed` although everything it set out to do is done, and the pull request that already covers the branch is neither found nor reported | P4e (the publication asks GitHub for the branch's open pull request into the base before opening one, and reports the one it finds) | +| F66 | loop | A fix appended onto a branch that `main` has moved past carries none of `main`'s changes, and the loop has no step that merges them, so the branch learns it conflicts with its base only from GitHub after the push — a publication's commit and `main`'s both edited the same paragraph, and the merge was made by hand | P4e (D15: the commission merges `origin/` and its agent resolves the conflicts; the publication re-makes that merge and folds the patch into it — one merge commit) | +| F67 | executor | A publication whose whole change is the merge — a clean catch-up of `origin/`, or a turn that left every deletion the merge offered standing — is refused as having changed nothing; and a commission given `mergeFrom` without `base` merges onto the folder's HEAD into a patch no publication can take, with nothing refusing it before the turn | P4e (with `commit.mergeFrom` an empty patch lands the merge commit alone; `mergeFrom` without `base` is refused before any checkout) | +| F68 | executor | A Helmsman conversation that had driven work for three days failed at its 83rd turn with `conversation_history_hydration_failed`: a committed history batch could no longer be read, because conversation state is stored through the payload store like any step payload and the Redis store gives every payload its default 24-hour TTL; the error named the refs but not when each was committed, so an expired batch read the same as a corrupt one | P4e (committed conversation state is stored without a TTL; a hydration failure names each unreadable batch, the turn it was committed at and why) | +| F69 | executor | A check at another commit fails its type-check: a checkout mirrors the folder's installation but builds nothing, and every package type-checks against the others' compiled declarations, which `yarn install` writes through postinstall and a checkout never has. The mirror already resolved workspace links into the checkout; it was not the cause | P4e (the check first builds what the touched workspaces reference or import, in the checkout; workspace links are relative, so every path to the checkout resolves alike) | +| F70 | skill | A publication that fails after its commit leaves the branch it created on the machine, and a second publication of the same patch cannot reuse it — the branch exists and its head is not the patch's base — so the branch was deleted by hand before the retry | P4e (a publication whose commit is in place and whose later task failed resumes from that commit, or removes the branch it made when it fails) | +| F71 | platform | `persist` on committed conversation state covers the conversation store and compaction, not the two other writers of those kinds — human-task hydration stored as `state`, the ai-client's `history` — so the rule is stated where it does not hold; and a Redis payload store that never expires conversation state keeps every superseded per-turn state with nothing that removes it | P4e (every writer of a durable kind persists, guarded by a test over the kinds; removal of superseded conversation state at the terminal flush is deferred, not built) | +| F72 | executor | A restart of the host executor ends every process it started and discards every session's checkout, so a merge into the stack's checkout ends every commission, check and review in flight, and `tsx watch` kills five seconds after its signal | P4e (D17: the host executor drains on SIGUSR2, which only the dev stack's watcher sends, and waits for the exit; SIGTERM still ends everything) | +| F73 | executor | A checkout takes its third-party dependencies from the folder's installation, and that installation lags the lockfile whenever the operator has not run `yarn install` since pulling — the first real check on a merged tree failed on `playwright-core`, a dependency the lockfile had carried for a day and the folder had never installed | P4e (the check compares the folder's installation state with the checkout's lockfile and refuses by name when they differ, before building anything; the folder's `yarn install` stays the operator's) | +| F74 | executor | A drained restart waits for the steps in flight but not for a session between its turns: on exit it discards every kept session's checkout, so a merge between the turns of a continued commission ends that session and whatever its coding agent left uncommitted | Deferred, not built (a kept session that survives a restart, its checkout left on disk and taken up again by the next executor; until then a stream continuing a session merges only once that commission has published) | +| F75 | workbench | The workbench's recent conversations do not say whether a conversation is active — Helmsman mid-turn, or a commission or publication it started still running — so the operator opens each one to find out | P2 (a conversation's row carries its live state: the agent's turn, the runs it waits on and their phase, from the session hot state and the attention list) — delivered: the session list reads each status from the hot state, the projection emits `entity.session.status` when a turn begins or rests, and the row joins the Workbench's active-run feed; no request per row | +| F76 | loop | A commission that runs past `host.harness.run`'s default timeout is killed mid-turn with its work on disk and no result, and the conversation that started it reported the kill as a 27-second off-script run that created another stream's branch — an account that matched nothing in the run's own activity log | P4e (a killed commission returns `killed: { afterMs, byTimeout }` beside its patch so the next brief continues it; a result the agent cannot explain is reported as the data it carries, never as a story) | +| F77 | op | A commission's `maxTurns` is a number the briefing agent picks, and a brief that asked for a generous budget got 40 — a slice of this size spends that reading the code — so the run ended before its first edit and the result could only say so; a cap sized to a guess is the length-cap mistake again | P4e (the operation's default turn budget is sized for the work it exists to run, several hundred, named with its reason; a run that reaches the cap says `turnCapReached` as data beside its patch) | +| F78 | readiness | A model provider refusing for credit shows in the conversation that met it, with the error and a retry that works, and nowhere else: another stream, or a driver reading run and session state, learns of it only by trying that provider again | P2 (the space's readiness carries the provider's last refusal beside the missing-key line, cleared by the next accepted call, so every conversation and every watcher sees it once) | +| F79 | skill | A publication that waits for an approval measures `origin/` at its scan and pushes much later, so a base that moved while the operator was away makes the push gate refuse the receipt as covering another range — the gate is right, and the run has no way to scan the range it now sends and push again; the branch stays on the machine with an approved commit | P4e (a push refused for a moved base re-measures, re-scans the new range and, where the posture requires it, re-reviews before it pushes again, inside the same run) | +| F80 | executor | The folder's check runs the test files a commit touches, so a schema that gained a required field passed the check while a fixture in an untouched test file elsewhere no longer parsed — the review caught it by reading, and CI would have; the check did not | P4e (the check also runs the tests of every workspace that depends on a touched package, bounded by the same budget, so a contract change meets its consumers before the push) | +| F81 | executor | A check's stored output is cut at a size from its start, and a test sweep's failure is at its end, so the first check that failed in the dependents' sweep stored 88 lines of passing tests and no failing one; the conversation that started it could only say that something after the visible tail failed | P4e (the stored output keeps its head and its tail with the cut marked between them, and the check's test step prints failures and the summary rather than every passing file) | +| F82 | executor | The first dependents' sweep ran 842 test files under the check's sandbox and failed eleven that pass on `main`: ten start a local server and time out at exactly their five-second limit, so the sandbox refuses loopback, and one is a pid-timing test run among 842 files on a laptop that also runs the stack | P4e (the sweep runs the test files whose imports reach a touched file, not every dependent's suite, with vitest's workers bounded; a test that serves itself declares the `listener` tag, runs under the check on Linux in the sandbox's own namespace, and on macOS, where the sandbox gives it no loopback, is left out by name, counted in the receipt, and run in CI (D19)) | +| F83 | executor | A commission cannot add a dependency: its checkout mirrors the folder's installation and the sandbox reaches no registry, so a change that needs a package was finished by hand or by the agent editing `yarn.lock` directly, and the check then runs against an installation that lacks it (the Plan 320 stream's 320-F2) | P4e — under `open` (D19), the registry is reachable, so the package manager writes the lockfile; no commission writes the folder's installation under either posture, so a changed lockfile is published with an install step the operator runs once, named in its result, and the check refuses it until then | +| F84 | executor | Inside a commission every shell command exits 1 with a sandbox refusal about a temporary working-directory file even when the command succeeded, so the agent's own checks all read as failures and its reports of them cannot be trusted; the run's activity log shows every tool result `ok: false` for weeks (320-F26) | P4e — retired under `open` (D19), whose shell writes its file in the job's own temporary directory, which `CLAUDE_CODE_TMPDIR` names, measured by the lane's boot self-test, where a shell command must exit 0; `confined` hands it the same directory | +| F85 | executor | `yarn` is unavailable inside a commission because Corepack fetches the pinned Yarn over a network the sandbox closes, so the agent cannot run the repository's test command and improvises (320-F3) | P4e — retired under `open` (D19), whose sandbox reaches the hosts Corepack fetches the pinned Yarn from, measured by the boot self-test, where `yarn --version` must run; remains the confined posture's: the lane provides the pinned Yarn from the folder's installation or a cached release | +| F86 | executor | The sandbox refuses writes to agent configuration at the repository root (`.mcp.json`), which has to be added by hand (320-F28) | P4e — not retired by `open` (D19): the sandbox protects agent configuration files under both postures; the policy names the agent-configuration files a commission may write, or the lane writes them from the harness profile | +| F87 | skill | The secret scan stops on ordinary code — `?token=abc123` in a test URL, a CSS selector held in a constant whose name contains `password` — and its remedy, recommissioning on a fresh branch, repeats the whole commission; the local commit holding the line was amended by hand (320-F12, 320-F25) | P4e (the rules exempt test fixtures and selector-shaped values by evidence, and a finding in a local unpushed commit is cleared by removing that commit from the branch and publishing the corrected patch, inside the loop) | +| F88 | op | `host.commit.scan` reports `PROJECTION_FAILED` on `unflaggedRange` and `receipt` beside a real finding, because a scan that found something has no receipt and the projection expects one (320-F13) | P4e (the projection declares which outputs a finding leaves absent, so the failure carries the finding alone) | +| F89 | loop | Two streams take the same tenant migration number: a commission takes it from `origin/main` at start and the collision shows only as a conflict at GitHub after the push (320-F19) | P4e (the migration index is checked at publication against `origin/` as the push is measured, and a taken number fails the publication by name before the push) | +| F90 | loop | Each review pass finds new issues in code an earlier pass already read, so a publication is a loop of unknown length and every turn of it is a full commission (320-F15) | P4e (a review's findings are recorded on the pull request as the handoff Plan 321 uses, so a later pass reads what the earlier one judged and says what changed; depth `deep` runs the checks and is the default for a branch's first review) | +| F91 | mcp | A session that fails on its model provider is opaque through MCP: `inspect_session` and `watch_session` show `FAILED` with every step unknown and no error, and offer no retry, while the web shows the provider's error and a retry; the cause was readable only from the step's stored error payload (320-F23, 320-F29) | P2 (the MCP's session read carries the failed step's error and the retry the web offers; the readiness line of F78 says it once for every surface) | +| F92 | op | Helmsman's first read of a harness result guesses a field that is not there (`/data`), spending one step per commission on a `MEMORY_NOT_FOUND` that lists the real fields (320-F5) | P4e (the result's shape is in the operation's output description and the commission skill's result guidance, so the first read names a field that exists) | +| F93 | platform | No check exercises an operation against the capability profile a real local space holds (Personal Safe), so a capability-migration gap shows only on the first live call (320-F17) | P4e (a contract test holds every registered operation's capability against each system profile's allowed set and fails on an operation no local profile can call) | +| F94 | loop | A cancelled publication wakes the conversation that started it, and Helmsman answered the wakeup by commissioning a fix round nobody asked for, from the review findings it had — thirty minutes of coding agent on a shape the operator had not chosen | P4e (a cancellation's wakeup says who cancelled and that nothing is asked; a commission is started only from a message in the conversation) | +| F95 | workbench | A commission started as a run shows, in the workbench's run view, a coding-agent task that is `running` with `0ms` and "has not reported yet" for its whole half hour: the harness's live feed reaches the chat card of a direct call and no surface of a run, so the operator cannot tell a working commission from a stuck one | P2 (the run view's harness task shows the same live feed the chat card shows — the agent's last tool line, elapsed time, turns — from the step's activity stream) — delivered: a run's live-delta wake names its task steps, so a view opened mid-task reads their buffers; the run container folds the frames and mounts `HarnessStepCard`, the chat's card, timed from the task's start | +| F96 | loop | Helmsman's link to a commission run is a `sessions/` address, which the web answers with "Failed to fetch run"; the run lives under `runs/` | P2 (the link a run result carries is built from the run's own route, by the platform, not composed by the agent) | +| F97 | workbench | The harness activity card is a scroll of tool invocations and their exit codes, unreadable in real time and misleading while F84 marks every command a failure; what an operator watching a commission needs is proof of life, what the agent says it is doing, any plain-text message it writes, and the files it is changing | P2 (the card leads with the agent's last status or thought and the time since the last activity, then the files touched, with the tool lines collapsed behind a disclosure; exit codes shown only once F84 makes them true) — delivered: the activity line carries `intent`, a call's `changes` and a result's `command`; a command's result shows no verdict until F84 is retired | +| F98 | executor | Under `open` the sandbox opened the machine's whole loopback to every coding agent and check, and the stack's Redis listens on 6379 with no password and holds the `aflow:write-approval:*` grants the push gate reads, so code an agent wrote could mint a grant and push past the gate | P4e (D19: no port list — the machine's loopback is never a job's under either posture; the `open` launcher refuses this machine on every port, and the job's loopback is the namespace's own on Linux and none on macOS); the second half is P4e (D20): every service of the stack requires a credential, Redis the machine's password, which `REDIS_URL` is given | +| F99 | skill | A loop pull request's title and body are the last publication's title and a one-paragraph summary written in the brief, so a branch of eight commits is titled by its final fix and its body says nothing a reader can act on; a pull request written by hand (#64) carries what changed as a table where one fits, the decisions as bullets, the known gaps, and what was verified and what was not | P4e (Local Publish composes the pull request from the branch as a whole: the slice's title, what changes from every commit's message, the review's findings closed and left open, the check's results as verification, and the gaps the commissions recorded; on an appended push it rewrites the body rather than leaving the first one) | +| F100 | loop | A publication's `patchRef` and `baseSha` are retyped by Helmsman from a commission's result, and two publications of one patch failed at the commit on a sha with one wrong character and then on the wrong base, while the result the commission run holds was right both times | P4e (Local Publish takes the commission run's id and reads `patchRef`, `baseSha` and `merge.from` from that run's result itself; a brief names a run, not shas) | +| F101 | platform | Denying the stack's own ports under `open` cannot keep up with what listens on the machine: the stack's MCP server on 3100 hands the owner's API key to any local session that brings no credential, and `infra:tools` publishes Redis Commander and pgAdmin without a login, so a job that reaches loopback acts on the stack as the owner however many ports a list names | P4e (loopback under `open` is the job's own — the Linux namespace's, or on macOS none — and never the machine's; a test that needs a listener of its own runs in CI and is counted as skipped in the check's output; every local service of the stack requires a credential, F98's second half, as the independent layer) | +| F102 | skill | A publication that has pushed fails on a GitHub request that times out at the pull-request step, so the branch is on `origin` with no pull request and the run reads `failed` for a change that is published; the step was retried by hand through Helmsman once GitHub answered | P4e (the pull-request steps retry a timed-out request within the run, and a publication whose push succeeded ends `completed` with the pull request's absence named rather than `failed`) | +| F103 | platform | A Helmsman conversation re-sends its whole history every turn, and compaction keys on the model's context window, so on a million-token model nothing is ever compacted and a conversation that drives a day's rounds pays for ninety thousand tokens of its own past on each of dozens of turns — thirty dollars a day on one conversation, and the provider's monthly limit reached in a day | P4e (compaction keys on cost and age as well as the window: a turn's history is folded once its re-read cost crosses an operator knob, old tool results and superseded briefs first, and a conversation's running spend is on the space beside the model) | +| F104 | executor | The sandbox denies writes to `.mcp.json` in the checkout (`operation not permitted`), the coding agent's edit of it failed twice, and both times the commit's title and the plan text claimed the change — a refused write surfaces as one line among fifty tool results, and nothing in the result says a file the task named could not be written; the change was made by hand on the branch | P4e (a write the sandbox refuses inside the checkout is reported on the result as `refusedWrites`, and a task that names a file the policy denies is refused before the turn; F86's measurement: the denial is the sandbox's own protection of agent-configuration files) | +| F105 | platform | The Redis integration suites probe `127.0.0.1:6379` with no credential and skip themselves when the probe fails, so the moment the dev Redis gained a password every one of them stopped running locally with no failure anywhere, while CI's unprotected Redis still ran them — a protected service made the tests disappear rather than fail | P4e (one shared probe reads the stack's own `REDIS_URL`; no Redis skips, a Redis that refuses the credential fails the suite by name) | +| F106 | platform | The Redis password was generated into each checkout's `.env` while every checkout on the machine shares one Redis container, so a second worktree's first `yarn start` wrote a password the running Redis never had and every service in it failed with WRONGPASS, and its `infra:up` would have recreated the container and broken every other checkout; the readiness check read a NOAUTH reply as "protected" without ever trying the password it carried | P4e (a credential for a shared container lives in one machine-level file the stack reads and every checkout's `.env` derives from; readiness authenticates with the password it holds; a second checkout adopts the machine's credential instead of minting one) | +| F107 | skill | The commission brief lists the agent's checks by hand (`npx eslint` on "touched sources"), the agent linted the TypeScript it touched and not the `.mjs` test it added, and the folder's check refused the publication on that one error — a round of the loop (commission, commit, build, check: about twenty-five minutes) spent on what the folder's own check would have told the agent in its checkout, because the agent runs a list retyped from the protocol while the folder declares the check that decides | P4e (Commission Change carries the folder's declared check into the agent's task as the proof it must run from its checkout, `AFLOW_CHECK_BASE...AFLOW_CHECK_SHA` set as a publication sets them, so the brief names no checks and the agent cannot run a narrower list than the gate) | +| F108 | skill | The folder's check selects tests by import reachability from the touched files, so the two guards in `packages/schemas/src/edition/mcpRegistration.test.ts` that read the root `.mcp.json` never ran when the branch changed it; five publications passed the check and the push, CI failed on the guards, and the failure was found by hand in the morning — a test that reads a repository file is reached by no import, and a file outside every workspace is reached by no test the check knows; the round that taught the check to run the edition guards for such a file was itself missed the same way — `workspaceLinks.test.ts` runs `scripts/verify-commit.mjs` by path over a fixture repository, no import reaches it, and CI found the guard directory read unconditionally where the fixture has none | P4e (a touched file outside every workspace runs the repository-shape guards — the tests under `packages/schemas/src/edition` are what read such files — and the publication's report names them as run for that file; a test whose source names a touched file's path is reached by it, import or not, so a script run by a test meets that test before the push; the CI failures themselves are Plan 321's case: nothing on the platform noticed them) | +| F109 | platform | The laptop slept through the night with dark wakes every quarter hour; a brief posted in one wake window was consumed by the orchestrator at the next, after the executors' sixty-second heartbeats had expired in the sleep between and before they refreshed, so the enqueue refused the commission ("No executor available for step type: host") and then Helmsman's own next turn ("… ai") — an outage classified `transient` and `retryable` ended the workflow run and the conversation terminally, and the next round needed a new conversation | P4e (three parts, in cost order: the host executor holds a sleep assertion while a harness run, check or review is in flight — `caffeinate` on macOS, `systemd-inhibit` on Linux — released when idle, under a pairing knob `aflow harness keep-awake on-ac | never | always` defaulting to on AC; an enqueue refused for a missing executor parks the step and retries for a bounded time instead of failing it, the session's failure record keeps the step's own classification, and an executor refreshes its heartbeat on wake before the orchestrator's consumers resume; and a cold restart measured — a commission started, the stack killed and brought back, what each piece did recorded — so a retry continues the coding agent's own session (`continueFrom`) rather than restarting the brief; a sleeping or unplugged appliance is the local edition's normal night) | +| F110 | executor | While writing its result, a review's shell quoting error ran `yarn start` and executed `.env.example` inside the folder's checkout; under the `open` posture both failed at once — the Docker socket out of reach, tsx refused its IPC socket — and the checkout showed no change but `.aflow/`: the posture held where a review that runs no checks by contract had every means to start the stack by accident | P4e (none: the measurement D19 asked for — the sandbox, not the review's discipline, is what kept the stack's start from running; recorded as the evidence) | +| F111 | skill | With a second stream's commission on the machine, the folder's check took 570 s and failed on one test the branch never touched — `saveResponse.test.ts` writing an 11 MB body timed out at Vitest's 10 s default — so a passing commit was refused for load; Helmsman retried the check on the same commit through `workflow.run.resume` on its own and it passed | P4e (the check's test run on a shared machine sets a per-test timeout sized for load, one constant, and a test that measures a body's size declares its own; a check that fails on a timeout alone is retried once by the publication itself before the operator hears of it, and the retry is recorded on the task) | +| F112 | skill | Every publication builds the touched workspaces' whole reference closure cold in its detached checkout — about twenty packages for an orchestrator change — so a check takes five to nine minutes of which lint is seven seconds and the tests two; the operator read the wait as the lint step | P4e (the check's live output names the step it is on, so the run view says "building @aflow/schemas" rather than "running"; the cold build stays, since no key for a cache across checks holds against the check that made the build) | +| F113 | skill | `main` moved while a publication ran (two other pull requests merged between its scan and its push), so the push gate refused the receipt — the scan covered the range from the old `main`, the push would send the range from the new one — and a reviewed, approved commit sat unpushed with the remedy addressed to a person: "scan the range the push sends"; the round was repeated with `mergeFrom: origin/main` by hand | P4e (the publication computes the range it will push at push time and, where `origin/main` moved since the scan, scans that range itself and pushes with the fresh receipt — one more scan, no round; the same re-scan covers a receipt the host executor no longer recognises because it restarted between the scan and the push, which failed a publication the same way after the stack restart of F116; where the move conflicts, it reports the merge round as the remedy by name) | +| F114 | process | The plan document is the one file every stream writes — each slice its D-text and delivery note, each docs round its finding rows — so `main` moving under a pull request conflicts there and nowhere else: #68 once, #72 twice in one afternoon, each costing a merge round of the loop (commission, check, scan, review, push: about twenty minutes) to resolve a conflict with no content in it | P4e (the findings table moves to its own append-only file beside the plan, `315-findings.md`, that no slice edits and every docs round appends to, so a D-text and a finding row never meet; the publication, meeting a conflict whose every hunk is append-only, resolves it itself — F113's re-scan then carries the merge too) | +| F115 | platform | With two coding agents, a check and a browser session on the machine, a backend of the development Postgres exited with code 2 and the postmaster restarted every backend — ten seconds of fsync, automatic recovery, nothing lost; the stack logged 137 refused connections ("the database system is in recovery mode") in one second, every service reconnected, ten heartbeats held, and the publication and review in flight continued; the Postgres log names no client, so the cause is unknown | P4e (none yet: the stack rode through a database crash as designed — recorded as the evidence; if it recurs, `log_connections` and the client's `application_name` on every pool are the first measurement, since every connection today is `phoenix-unknown`) | +| F116 | platform | The database restart (F115) killed the orchestrator: the Postgres client threw `Cannot read properties of null (reading 'write')` from `connection.js nextWrite` on the dropped connection, the process exited, and `tsx watch` — which restarts on a file change and not on a crash — left it dead; the stack's ports stayed up, the web app answered, ten executor heartbeats held, and for forty minutes nothing consumed the control, result or timer streams: a review's result was never applied, three Helmsman messages were accepted and never ran, a `workflow.run.cancel` stalled, and every stream on the machine stopped without a single signal that the orchestrator was gone | P4e (the orchestrator survives a dropped database connection — the client's abrupt-close path is caught and the pool reconnects, with a test that kills the connection under a write; the dev runner restarts a service whose process exits, as the appliance's supervisor does; the orchestrator publishes a heartbeat of its own and `yarn start`'s health, the MCP `auth_status` and the web's status line say when no orchestrator is consuming) | +| F117 | skill | A publication that committed, checked, scanned and was approved, then had its push refused, strands its commit: the push cannot be retried because the receipt is baked into the task's input, a new publication cannot append because the branch has moved to the commit it would push, the agent cannot re-scan because `host.commit.scan` is the publication machinery's and not the Helmsman's, and the only lever it could name was a token change to give the machinery something to commit — the merge commit `3345f45` on the F109 branch sat reviewed and unpushed | P4e (a publication of a branch as it stands — no patch — runs check, scan, review and push on the branch head, so a reviewed commit the machinery already made is never stranded; with F113's re-scan at push, a refused push repeats its scan and pushes instead of ending the run) | +| F118 | platform | After a merge that added an export to `@aflow/schemas`, `yarn start` printed "Stale dist/ in 8 package(s) — safe to ignore in dev (ts-source resolves to src/ directly)" and the web app failed on its first page: the Next application resolves packages through `dist`, so the stale build the message dismissed was the one the browser loaded (`Export browserApprovalActionPhrase doesn't exist in target module`), while every Node service, on `ts-source`, was fine; the operator saw the error, the stack reported health | P4e (the start rebuilds every stale package the web application reads before it serves it, or runs them in watch as it does `@aflow/web-product`, and the stale-dist line names the packages and says which consumer reads `dist`; a dev health check requests the web app's first page and fails loudly on a module error) | +| F119 | skill | A review that reached its verdict and lost it writing the result — the harness's last call met the provider's 429 — failed the publication's review task with no verdict, and the publication paused at `approve-push` with its commit already on the branch; the remedy taken was to cancel it, which stranded the commit (F117), and the right one — resuming the publication with the failed review task retried — was named by no surface | P4e (a review that fails with no verdict is retried once by the publication itself, as the check is on a timeout (F111), and a publication paused at `approve-push` after a failed review names the retry as its remedy) | +| F120 | skill | A cold Helmsman conversation that continued the stream from its plan node commissioned correctly but with none of the loop's standing inputs — no `wait: 'none'`, no model, no base, no turn budget — because those live in the protocol document and in the operator's briefs, not in the skill; the run waited on its commission with the conversation blocked, as every early round did before D13 | P4e (Commission Change's defaults are the protocol's: `wait: 'none'`, `base: origin/main`, the folder's default model, the two-hour timeout and the several-hundred-turn budget, each overridable; a brief names an input only to change it) | +| F121 | executor | The fix round's commission named the F116 commission's kept session (`continueFrom`) and was refused as expired or lost to a restart — neither had happened: a kept session is owned by the run that made it and every commission is a new run, so from a conversation a commission can never continue another's session, while the protocol page says a stream continues one | P4e (a kept session is owned by the conversation that started the run that made it, which the orchestrator stamps on the run's operation-task jobs as `startingConversationId`, so a fix round from that conversation continues the coding agent's session; a run no conversation started owns its own; a retry continues only its run's session, matched on the run and the work alone since a retry is the same run; another conversation's session is refused as a missing one is, stating the conversation rule and how a kept session ends; a stalled run recovered from another conversation carries that conversation from then on, so the run's later sessions belong to the recovering conversation while those it made before the recovery still belong to the one that started it — the intended rule, not a defect) | +| F122 | skill | Before the fix round's commission took, Helmsman put `wait` among the task's inputs and then `timeoutMs` at the run level, each refused with a teaching error, two turns spent; and with the commission running it asked whether to publish when it landed, a question the brief had answered, holding the round until a person chose | F120 (an input a brief need not name cannot be misplaced); the skill's step after a commission's end is the publication, not a question | +| F123 | executor | The F113/F117 slice's own publication was refused at the push twice over: `main` moved during its run, the scan receipt was refused for the range (F113), and when the operator re-scanned the pushed range by hand the check receipt was refused next, because it binds to the base as the scan's does and a re-check against the moved base is the folder's forty minutes; both refusals name their remedy — `host.commit.scan`, `host.commit.check` — to the agent that reads them, and that agent is refused their promotion (`out_of_scope`), so the commit went up with a plain `git push` from the folder | P4e (the publication re-scans and re-checks at push time itself, or the ops are promotable to the agent that reads the refusal, or the refusal names a remedy that reader has; tests cover the wording of both refusals) | +| F124 | executor | A merge round's publication scanned `main..head` and read the merge commit against each of its parents, so `main`'s own content — a binary already on `main` — counted as added and unread, the scan was unclean, the review was skipped and the push asked the operator; every later publication of that branch meets it again, because the merge commit stays in the range | P4e (the scan reads what the range adds relative to the push base, a commit's own diff against its first parent and never a merge's second-parent diff; a receipt's `unscanned` names only files the range itself adds) | +| F125 | skill | PR #94's CI failed one tree-walking guard (`backgroundWork.test.ts`, which took the git secret scan's `history.scan(` for a Redis keyspace scan) that the folder's check never ran: the guard lives in `@aflow/schemas` and is import-reached by nothing the slice touched, the third miss of this shape after F108's two | P4e (the check runs every tree-walking guard whenever a production source is touched — delivered for the guards that read the background-work scanner (#94); the durable-payload guard and any other tree-walker that reaches no shared module are still selected by import alone) | +| F126 | process | Four times in five days a required check failed on the open slice for reasons outside it: the dependency audit runs only in pull requests, so an advisory published against a transitive package is first met by whichever slice is open, each wave needed a pin pull request of its own (#96, #97, #106), and a rerun of the failed job tests the merge commit the run was created with, never the moved `main` | Plan 321 (the audit runs on `main` on its own schedule and a pin round opens its own pull request, so a slice's CI answers only for the slice; a slice whose base moved is retested by a branch update, not a rerun) | +| F127 | realtime | Opening the chat page's Workbench panel fired the conversation list, the presence lookup and the active-runs query in lockstep at about a hundred requests a second until the read limit refused everything, the Store's requests with them: the panel's first subscription to the space's entity events names no cursor, the gateway read that as `'0'` and drained the whole stream — Full Circle's 2,149 events, 1,065 `entity.session.described` and 1,024 `entity.run.updated` — and two hooks refetched on every one, each refetch paced by the server's response time; pinning TanStack Query and Next back changed nothing | P4e (a subscription naming no cursor starts at the stream's live tail and one naming a cursor resumes from it; the active-runs feed and the conversation list refetch once per burst of events, not once per event; `entity.session.described` is emitted when the job changes a title or summary and when a rename changes any field a window holds, revision included; a reconnect before any event resumes from the subscription's acknowledged cursor; a test covers each) | ## 7. Migration and breaking changes