diff --git a/docs/design/decisions/0031-decision-run-control-records.md b/docs/design/decisions/0031-decision-run-control-records.md index 9af9ab2..c40245d 100644 --- a/docs/design/decisions/0031-decision-run-control-records.md +++ b/docs/design/decisions/0031-decision-run-control-records.md @@ -56,3 +56,23 @@ interrupted parked runs can resume without re-asking. - Ask-why and replay can cite decision/refusal records without treating them as story transitions. - P12 can expose `decide` and `stop` through MCP later as thin calls over the same SDK surface. - Multi-user authentication and remote approval channels remain outside this decision. + +## Addendum - Live-run out-of-band decisions + +The out-of-band `decide` surface also answers a live run. The records projection exposes a +routed-decision surface naming the currently parked routed story: for a stopped run it is derived +from the parked safe checkpoint (unchanged), and for a live run it is the latest still-parked +story. `decide` appends the same `owner-decision.recorded` record either way; no new lifecycle +state or event family exists. + +A running harness polls the durable log for a pending decision at the same safe story boundaries +where it polls stop requests. Consumption records only the existing authorization consequence - +`authorization.granted` for approve/override (and the story re-executes, mirroring resume's +approved-park semantics), `authorization.denied` plus the owner-rejection block for reject, and +the `authorization.routed` re-target for hand-off, which keeps the story parked. A hand-off's +routed record is therefore legal against a parked story in replay. + +Precedence is stop-first: when a stop request and a decision are both pending at a boundary, the +harness consumes the stop and leaves the decision durable and unconsumed for resume. A decision is +pending only when it answers the story's latest park and no authorization consequence follows it, +so a decision consumed live is never re-applied by resume or by a later boundary. diff --git a/packages/jig-sdk/src/harness.ts b/packages/jig-sdk/src/harness.ts index 7a9d459..c58c8c4 100644 --- a/packages/jig-sdk/src/harness.ts +++ b/packages/jig-sdk/src/harness.ts @@ -44,6 +44,23 @@ interface StopRequest { reason: string; } +interface PendingLiveDecision { + outcome: OwnerDecision; + handedOffTo?: string; + requestId?: string; + requestKind?: string; +} + +interface LiveParkState { + runStatus: RunStatus; + stopReason: string; + hasUnattendedPark: boolean; + unattendedParkCheckpoint: string | null; + checkpointStoryId: string | null; +} + +const PARKED_CHECKPOINT_SUFFIX = '.parked'; + function normalizeOwnerDecision(decision: OwnerDecisionResult): NormalizedOwnerDecision { if (typeof decision === 'string') { return { outcome: decision }; @@ -157,6 +174,7 @@ export class LocalHarness { private readonly landingAction: LandingAction; private readonly blockSurface: HarnessPorts['blockSurface']; private readonly workspaceIsolation: PerStoryWorkspaceIsolation | undefined; + private readonly storyWorkspaces = new Map(); constructor( worker: Worker, @@ -318,6 +336,170 @@ export class LocalHarness { return request; } + private pendingLiveDecision(storyId: string): PendingLiveDecision | null { + const readEvents = this.recordManager.readEvents; + if (!readEvents) { + return null; + } + + // A decision is pending when it answers the story's latest park and no authorization + // consequence has been recorded for the story since. The subsequent granted, denied, or + // routed record is what marks a decision consumed, so one durable decision is applied once. + let parkedRequest: { requestId?: string; requestKind?: string } | null = null; + let pending: PendingLiveDecision | null = null; + for (const event of readEvents.call(this.recordManager)) { + if (event.storyId !== storyId) { + continue; + } + if (event.family === 'story.parked') { + parkedRequest = { + ...(typeof event.requestId === 'string' ? { requestId: event.requestId } : {}), + ...(typeof event.requestKind === 'string' ? { requestKind: event.requestKind } : {}), + }; + pending = null; + continue; + } + if ( + pending && + (event.family === 'authorization.granted' || + event.family === 'authorization.denied' || + event.family === 'authorization.routed') + ) { + pending = null; + continue; + } + if (!parkedRequest || event.family !== 'owner-decision.recorded') { + continue; + } + const outcome = event.outcome; + if (outcome !== 'approve' && outcome !== 'reject' && outcome !== 'override' && outcome !== 'hand-off') { + continue; + } + const requestId = typeof event.requestId === 'string' ? event.requestId : parkedRequest.requestId; + const requestKind = typeof event.requestKind === 'string' ? event.requestKind : parkedRequest.requestKind; + pending = { + outcome, + ...(typeof event.handedOffTo === 'string' ? { handedOffTo: event.handedOffTo } : {}), + ...(requestId ? { requestId } : {}), + ...(requestKind ? { requestKind } : {}), + }; + } + return pending; + } + + private parkedStoryIdFromLiveCheckpoint(checkpoint: string | null): string | null { + if (!checkpoint?.endsWith(PARKED_CHECKPOINT_SUFFIX)) { + return null; + } + return checkpoint.slice(0, -PARKED_CHECKPOINT_SUFFIX.length); + } + + // Consumes a durable out-of-band owner decision for the currently parked routed story at a + // safe boundary. Callers poll this at the same boundaries as latestStopRequest(), and only + // when no stop request is pending: a stop and a decision arriving together resolve stop-first + // (the conservative order), leaving the decision durable and pending for resume. + private async consumePendingLiveDecision( + stories: Story[], + policy: PolicyDoc, + state: LiveParkState, + blockedStoryIds: Set, + ): Promise { + const parkedStoryId = this.parkedStoryIdFromLiveCheckpoint(state.unattendedParkCheckpoint); + if (!parkedStoryId) { + return state; + } + const story = stories.find((entry) => entry.id === parkedStoryId); + if (!story) { + return state; + } + const decision = this.pendingLiveDecision(parkedStoryId); + if (!decision) { + return state; + } + + if (decision.outcome === 'hand-off') { + // Same consequence as an interactive hand-off: record the routed re-target and keep the + // story parked; the run still stops at the parked checkpoint. + this.recordManager.recordEvent({ + family: 'authorization.routed', + storyId: parkedStoryId, + requestId: decision.requestId, + requestKind: decision.requestKind, + basis: ['owner-hand-off'], + ...(decision.handedOffTo ? { handedOffTo: decision.handedOffTo } : {}), + }); + return state; + } + + if (decision.outcome === 'reject') { + this.recordManager.recordEvent({ + family: 'authorization.denied', + storyId: parkedStoryId, + requestId: decision.requestId, + requestKind: decision.requestKind, + basis: ['owner-rejection'], + }); + await this.recordBlockedStory(parkedStoryId, 'owner-rejection'); + return { + runStatus: 'failure', + stopReason: 'work-item-blocked', + hasUnattendedPark: false, + unattendedParkCheckpoint: null, + checkpointStoryId: parkedStoryId, + }; + } + + this.recordManager.recordEvent({ + family: 'authorization.granted', + storyId: parkedStoryId, + requestId: decision.requestId, + requestKind: decision.requestKind, + basis: [decision.outcome === 'override' ? 'owner-override' : 'owner-approval'], + }); + // Mirror resume's approved-park semantics: re-execute the story without re-recording + // story.started, while reusing the parked story's isolated workspace instead of allocating + // a second one or falling back to the ambient checkout. + const result = await this.executeStory(story, policy, false, { + workspace: this.storyWorkspaces.get(parkedStoryId), + }); + if (result.status === 'success') { + blockedStoryIds.delete(parkedStoryId); + const stopRequest = this.latestStopRequest(); + if (stopRequest) { + return { + runStatus: 'failure', + stopReason: stopRequest.reason, + hasUnattendedPark: false, + unattendedParkCheckpoint: null, + checkpointStoryId: parkedStoryId, + }; + } + return { + runStatus: state.runStatus, + stopReason: 'work-item-blocked', + hasUnattendedPark: false, + unattendedParkCheckpoint: null, + checkpointStoryId: state.runStatus === 'failure' ? parkedStoryId : null, + }; + } + if (result.stopReason === 'unattended-park') { + return { + ...state, + stopReason: 'unattended-park', + hasUnattendedPark: true, + unattendedParkCheckpoint: result.checkpointStoryId, + checkpointStoryId: result.checkpointStoryId, + }; + } + return { + runStatus: 'failure', + stopReason: 'work-item-blocked', + hasUnattendedPark: false, + unattendedParkCheckpoint: null, + checkpointStoryId: result.checkpointStoryId, + }; + } + private async recordBlockedStory( storyId: string, reason: string, @@ -571,7 +753,8 @@ export class LocalHarness { const batch = plan.stories.slice(index, index + ceiling); const results = await Promise.all(batch.map((story) => this.executeStory(story, policy, true))); const failed = results.find( - (result): result is Extract => result.status === 'failure', + (result): result is Extract => + result.status === 'failure' && result.stopReason !== 'unattended-park', ); const unattendedPark = results.find( (result): result is Extract => @@ -582,10 +765,33 @@ export class LocalHarness { stopReason = 'unattended-park'; unattendedParkCheckpoint = unattendedPark.checkpointStoryId; checkpointStoryId = unattendedPark.checkpointStoryId; - for (const remainingStory of plan.stories.slice(index + ceiling)) { - unstartedStoryIds.push(remainingStory.id); + // Stop-wins: only consume a pending out-of-band decision when no stop request is + // queued at this boundary; a queued stop leaves the decision pending for resume. + if (!this.latestStopRequest()) { + const parkState = await this.consumePendingLiveDecision( + plan.stories, + policy, + { runStatus, stopReason, hasUnattendedPark, unattendedParkCheckpoint, checkpointStoryId }, + blockedStoryIds, + ); + runStatus = parkState.runStatus; + stopReason = parkState.stopReason; + hasUnattendedPark = parkState.hasUnattendedPark; + unattendedParkCheckpoint = parkState.unattendedParkCheckpoint; + checkpointStoryId = parkState.checkpointStoryId; + } + if (hasUnattendedPark) { + for (const remainingStory of plan.stories.slice(index + ceiling)) { + unstartedStoryIds.push(remainingStory.id); + } + break; + } + if (runStatus === 'failure' && !shouldContinueIndependentWork) { + for (const remainingStory of plan.stories.slice(index + ceiling)) { + unstartedStoryIds.push(remainingStory.id); + } + break; } - break; } if (failed) { runStatus = 'failure'; @@ -642,6 +848,17 @@ export class LocalHarness { } break; } + const parkState = await this.consumePendingLiveDecision( + plan.stories, + policy, + { runStatus, stopReason, hasUnattendedPark, unattendedParkCheckpoint, checkpointStoryId }, + blockedStoryIds, + ); + runStatus = parkState.runStatus; + stopReason = parkState.stopReason; + hasUnattendedPark = parkState.hasUnattendedPark; + unattendedParkCheckpoint = parkState.unattendedParkCheckpoint; + checkpointStoryId = parkState.checkpointStoryId; continue; } @@ -651,6 +868,19 @@ export class LocalHarness { unattendedParkCheckpoint = storyResult.checkpointStoryId; checkpointStoryId = storyResult.checkpointStoryId; blockedStoryIds.add(story.id); + if (!this.latestStopRequest()) { + const parkState = await this.consumePendingLiveDecision( + plan.stories, + policy, + { runStatus, stopReason, hasUnattendedPark, unattendedParkCheckpoint, checkpointStoryId }, + blockedStoryIds, + ); + runStatus = parkState.runStatus; + stopReason = parkState.stopReason; + hasUnattendedPark = parkState.hasUnattendedPark; + unattendedParkCheckpoint = parkState.unattendedParkCheckpoint; + checkpointStoryId = parkState.checkpointStoryId; + } continue; } @@ -882,6 +1112,17 @@ export class LocalHarness { } break; } + const parkState = await this.consumePendingLiveDecision( + plan.stories, + policy, + { runStatus, stopReason, hasUnattendedPark, unattendedParkCheckpoint, checkpointStoryId }, + blockedStoryIds, + ); + runStatus = parkState.runStatus; + stopReason = parkState.stopReason; + hasUnattendedPark = parkState.hasUnattendedPark; + unattendedParkCheckpoint = parkState.unattendedParkCheckpoint; + checkpointStoryId = parkState.checkpointStoryId; continue; } @@ -923,18 +1164,30 @@ export class LocalHarness { } } - private async executeStory(story: Story, policy: PolicyDoc, recordStarted: boolean): Promise { + private async executeStory( + story: Story, + policy: PolicyDoc, + recordStarted: boolean, + options: { workspace?: StoryWorkspace } = {}, + ): Promise { const policyInForce = this.policyInForce(policy); try { - const workspace = this.workspaceIsolation?.allocate(story); - if (workspace && 'failureToken' in workspace) { - await this.recordBlockedStory(story.id, 'workspace-collision', { - diagnostics: { - error: 'Duplicate story launch refused', - failureToken: workspace.failureToken, - }, - }); - return { status: 'failure', stopReason: 'work-item-blocked', checkpointStoryId: story.id }; + let workspace = options.workspace; + if (!workspace) { + const allocatedWorkspace = this.workspaceIsolation?.allocate(story); + if (allocatedWorkspace && 'failureToken' in allocatedWorkspace) { + await this.recordBlockedStory(story.id, 'workspace-collision', { + diagnostics: { + error: 'Duplicate story launch refused', + failureToken: allocatedWorkspace.failureToken, + }, + }); + return { status: 'failure', stopReason: 'work-item-blocked', checkpointStoryId: story.id }; + } + workspace = allocatedWorkspace; + if (workspace) { + this.storyWorkspaces.set(story.id, workspace); + } } if (recordStarted) { diff --git a/packages/jig-sdk/src/projection.ts b/packages/jig-sdk/src/projection.ts index 73949aa..4b9b265 100644 --- a/packages/jig-sdk/src/projection.ts +++ b/packages/jig-sdk/src/projection.ts @@ -60,6 +60,12 @@ export interface ProjectedStory { lastEventFamily: string; } +export interface RoutedDecisionSurface { + storyId: string; + requestId?: string; + requestKind?: string; +} + export interface RunProjection { runId: string; planId: string; @@ -74,6 +80,7 @@ export interface RunProjection { stopCause?: string; summaryReason?: string; safeCheckpoint?: string; + routedDecision?: RoutedDecisionSurface; unstartedStoryIds: string[]; stories: Record; changedFiles: string[]; @@ -146,6 +153,10 @@ interface StoryAccumulator { diagnostics?: Diagnostics; changedFiles: string[]; lastEventFamily: string; + parkedRequestId?: string; + parkedRequestKind?: string; + lastRoutedRequestId?: string; + lastRoutedRequestKind?: string; } const VALID_RECORD_POSTURE = 'safe-for-owner-record'; @@ -545,6 +556,39 @@ function compareRunRecord( ]; } +function parkedStoryIdFromCheckpoint(checkpoint: string | undefined): string | null { + if (!checkpoint?.startsWith('after:') || !checkpoint.endsWith('.parked')) { + return null; + } + return checkpoint.slice('after:'.length, -'.parked'.length); +} + +function deriveRoutedDecision( + lifecycleState: ProjectionLifecycleState, + safeCheckpoint: string | undefined, + stories: Map, + parkOrder: string[], +): RoutedDecisionSurface | undefined { + const routedStoryId = + lifecycleState === 'started' || lifecycleState === 'resumed' + ? [...parkOrder].reverse().find((storyId) => stories.get(storyId)?.state === 'parked') + : lifecycleState === 'stopped' + ? (parkedStoryIdFromCheckpoint(safeCheckpoint) ?? undefined) + : undefined; + if (!routedStoryId) { + return undefined; + } + + // For stopped runs the checkpoint names the routed story even if a crafted log leaves it + // non-parked; decide() still owns the decision-not-parked refusal for that case. + const story = stories.get(routedStoryId); + return { + storyId: routedStoryId, + ...(story?.parkedRequestId ? { requestId: story.parkedRequestId } : {}), + ...(story?.parkedRequestKind ? { requestKind: story.parkedRequestKind } : {}), + }; +} + function checkpointMatchesStory(stories: Map, checkpoint: string): boolean { if (!checkpoint.startsWith('after:')) { return false; @@ -580,6 +624,7 @@ export function projectRunEvents(input: ProjectRunEventsInput): RunProjection { let summaryReason: string | undefined; let safeCheckpoint: string | undefined; let unstartedStoryIds: string[] = []; + let parkOrder: string[] = []; let sawEvidenceGateFailure = false; let sawAuthorizationDenial = false; @@ -775,15 +820,32 @@ export function projectRunEvents(input: ProjectRunEventsInput): RunProjection { story.lastEventFamily = family; break; case 'authorization.routed': - assertStoryState(story, ['started'], parsedEvent, storyId); + // A routed record is legal while parked too: hand-off re-targets the already-parked + // routed question (ADR 0031) without changing the story state. + assertStoryState(story, ['started', 'parked'], parsedEvent, storyId); + story.lastRoutedRequestId = + typeof parsedEvent.event.requestId === 'string' ? parsedEvent.event.requestId : undefined; + story.lastRoutedRequestKind = + typeof parsedEvent.event.requestKind === 'string' ? parsedEvent.event.requestKind : undefined; story.lastEventFamily = family; break; - case 'story.parked': + case 'story.parked': { assertStoryState(story, ['started'], parsedEvent, storyId); story.state = 'parked'; story.reason = typeof parsedEvent.event.reason === 'string' ? parsedEvent.event.reason : undefined; + const parkedRequestId = + typeof parsedEvent.event.requestId === 'string' ? parsedEvent.event.requestId : undefined; + story.parkedRequestId = parkedRequestId ?? story.lastRoutedRequestId; + story.parkedRequestKind = + typeof parsedEvent.event.requestKind === 'string' + ? parsedEvent.event.requestKind + : parkedRequestId === undefined || parkedRequestId === story.lastRoutedRequestId + ? story.lastRoutedRequestKind + : undefined; story.lastEventFamily = family; + parkOrder = [...parkOrder.filter((parkedStoryId) => parkedStoryId !== storyId), storyId]; break; + } case 'evidence.modeled': assertStoryState(story, ['started'], parsedEvent, storyId); appendChangedFiles(story.changedFiles, parsedEvent.event.changedFiles); @@ -928,6 +990,7 @@ export function projectRunEvents(input: ProjectRunEventsInput): RunProjection { stopCause, summaryReason, safeCheckpoint, + routedDecision: deriveRoutedDecision(lifecycleState, safeCheckpoint, stories, parkOrder), unstartedStoryIds, stories: Object.fromEntries( Array.from(stories.entries()).map(([storyId, story]) => [ diff --git a/packages/jig-sdk/src/resume.ts b/packages/jig-sdk/src/resume.ts index ef2c6ec..dccfe2a 100644 --- a/packages/jig-sdk/src/resume.ts +++ b/packages/jig-sdk/src/resume.ts @@ -593,9 +593,30 @@ function findParkedDecision(events: RunEvent[], parkedStoryId: string | null): R return undefined; } + // Only a decision recorded for the story's latest park is pending. A decision that already + // produced an authorization consequence was consumed - for example live, at a running + // harness's safe boundary - and must not be re-applied. + let sawPark = false; let decision: ResumePlan['parkedDecision']; for (const event of events) { - if (event.family !== 'owner-decision.recorded' || event.storyId !== parkedStoryId) { + if (event.storyId !== parkedStoryId) { + continue; + } + if (event.family === 'story.parked') { + sawPark = true; + decision = undefined; + continue; + } + if ( + decision && + (event.family === 'authorization.granted' || + event.family === 'authorization.denied' || + event.family === 'authorization.routed') + ) { + decision = undefined; + continue; + } + if (!sawPark || event.family !== 'owner-decision.recorded') { continue; } if ( diff --git a/packages/jig-sdk/src/sdk.ts b/packages/jig-sdk/src/sdk.ts index 1c07a4c..c5c4b38 100644 --- a/packages/jig-sdk/src/sdk.ts +++ b/packages/jig-sdk/src/sdk.ts @@ -250,33 +250,22 @@ function appendOwnerEvent(runDir: string, event: Record): void } } -function parkedStoryIdFromCheckpoint(checkpoint: string | undefined): string | null { - if (!checkpoint?.startsWith('after:') || !checkpoint.endsWith('.parked')) { - return null; - } - return checkpoint.slice('after:'.length, -'.parked'.length); -} - -function findLatestParkedRequest(eventsJsonl: string, storyId: string): { requestId?: string; requestKind?: string } { - let request: { requestId?: string; requestKind?: string } = {}; - for (const parsed of parseEventsJsonl(eventsJsonl)) { - const event = parsed.event; - if (event.family !== 'story.parked' || event.storyId !== storyId) { - continue; - } - request = { - requestId: typeof event.requestId === 'string' ? event.requestId : undefined, - requestKind: typeof event.requestKind === 'string' ? event.requestKind : undefined, - }; - } - return request; -} - function latestOwnerDecision(eventsJsonl: string, storyId: string): OwnerDecisionOutcome | null { + // Scope the duplicate check to the story's current park: a decision consumed on an earlier + // park answers that earlier routed question only, so a re-parked story accepts a fresh decide. + let sawPark = false; let outcome: OwnerDecisionOutcome | null = null; for (const parsed of parseEventsJsonl(eventsJsonl)) { const event = parsed.event; - if (event.family !== 'owner-decision.recorded' || event.storyId !== storyId) { + if (event.storyId !== storyId) { + continue; + } + if (event.family === 'story.parked') { + sawPark = true; + outcome = null; + continue; + } + if (!sawPark || event.family !== 'owner-decision.recorded') { continue; } if ( @@ -602,14 +591,17 @@ export function createJigSession(options: CreateJigSessionOptions = {}): JigSess const before = readProjectionInputs(input.runDir); assertOwnerAppendAllowed(input.runDir, before.eventsJsonl, 'owner decision'); const projection = projectRunEvents(before); - const routedStoryId = parkedStoryIdFromCheckpoint(projection.safeCheckpoint); - if (projection.lifecycleState !== 'stopped' || !routedStoryId) { + // The projection's routed-decision surface names the routed parked story for both a + // stopped run (via the safe checkpoint) and a live run (a running harness consumes the + // recorded decision at its next safe boundary). + const routedDecision = projection.routedDecision; + if (!routedDecision) { return appendDecisionRefusal(input.runDir, input, 'no-routed-decision'); } - if (input.storyId && input.storyId !== routedStoryId) { + if (input.storyId && input.storyId !== routedDecision.storyId) { return appendDecisionRefusal(input.runDir, input, 'decision-outside-routed-scope'); } - const parkedStoryId = input.storyId ?? routedStoryId; + const parkedStoryId = input.storyId ?? routedDecision.storyId; if (projection.stories[parkedStoryId]?.state !== 'parked') { return appendDecisionRefusal(input.runDir, { ...input, storyId: parkedStoryId }, 'decision-not-parked'); } @@ -620,12 +612,12 @@ export function createJigSession(options: CreateJigSessionOptions = {}): JigSess return appendDecisionRefusal(input.runDir, { ...input, storyId: parkedStoryId }, 'missing-hand-off-target'); } - const parkedRequest = findLatestParkedRequest(before.eventsJsonl, parkedStoryId); appendOwnerEvent(input.runDir, { family: 'owner-decision.recorded', storyId: parkedStoryId, outcome: input.outcome, - ...parkedRequest, + ...(routedDecision.requestId ? { requestId: routedDecision.requestId } : {}), + ...(routedDecision.requestKind ? { requestKind: routedDecision.requestKind } : {}), ...(input.reason ? { reason: input.reason } : {}), ...(input.outcome === 'hand-off' ? { handedOffTo: input.handedOffTo } : {}), }); diff --git a/packages/jig-sdk/tests/harness.unit.test.ts b/packages/jig-sdk/tests/harness.unit.test.ts index 26fe5e0..a758357 100644 --- a/packages/jig-sdk/tests/harness.unit.test.ts +++ b/packages/jig-sdk/tests/harness.unit.test.ts @@ -1,8 +1,13 @@ import assert from 'node:assert'; +import { mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { test } from 'vitest'; -import { LocalHarness } from '../src/harness.js'; +import { createInMemoryStoryWorkspaceIsolation, LocalHarness } from '../src/harness.js'; import { validatePlanForScheduling } from '../src/intake.js'; -import type { PlanInstance, PolicyDoc, ResumePlan, RunEvent } from '../src/types.js'; +import { RecordManager } from '../src/records.js'; +import { createJigSession, type DecideRunResult } from '../src/sdk.js'; +import type { ConfigDoc, PlanInstance, PolicyDoc, ResumePlan, RunEvent } from '../src/types.js'; test('LocalHarness sequential execution success', async () => { const worker = { @@ -1417,6 +1422,977 @@ test('P09-AC-5: interactive hand-off without a target is recorded as rejection', ); }); +interface LiveDecideRunFixture { + events: RunEvent[]; + harness: LocalHarness; + s1Executions: () => number; +} + +// Builds a two-story live run where s1 parks on a routed decision and the supplied out-of-band +// events become durable while s2 executes, exactly where an operator decide would land. +function liveParkedRunFixture( + outOfBandEvents: RunEvent[], + s1SecondExecution: () => Record = () => ({ + storyId: 's1', + outcome: 'success', + evidence: { result: 'passed' }, + }), +): LiveDecideRunFixture { + const events: RunEvent[] = []; + let outOfBandIndex: number | null = null; + let s1Executions = 0; + const harness = new LocalHarness( + { + execute: async (story: { id: string }) => { + if (story.id === 's1') { + s1Executions += 1; + if (s1Executions === 1) { + return { + storyId: 's1', + outcome: 'success', + evidence: { result: 'passed' }, + requests: [{ id: 'REQ-1', kind: 'edit-files', privileged: true }], + }; + } + return s1SecondExecution(); + } + if (outOfBandIndex === null) { + outOfBandIndex = events.length; + } + return { storyId: story.id, outcome: 'success', evidence: { result: 'passed' } }; + }, + }, + { + init: () => {}, + recordEvent: (event: RunEvent) => events.push(event), + readEvents: () => + outOfBandIndex === null + ? [...events] + : [...events.slice(0, outOfBandIndex), ...outOfBandEvents, ...events.slice(outOfBandIndex)], + finalize: async () => {}, + }, + ); + return { events, harness, s1Executions: () => s1Executions }; +} + +const liveDecidePlan: PlanInstance = { + plan: { + id: 'p1', + version: 'execution-plan-shape-v0', + stories: [ + { id: 's1', title: 'parks on a routed decision' }, + { id: 's2', title: 'keeps the run live' }, + ], + }, +}; + +test('P09-F2: a live run consumes an out-of-band owner approval at the next safe boundary', async () => { + const fixture = liveParkedRunFixture([ + { + family: 'owner-decision.recorded', + actor: 'owner', + storyId: 's1', + outcome: 'approve', + requestId: 'REQ-1', + requestKind: 'edit-files', + }, + ]); + + const status = await fixture.harness.run( + validatePlanForScheduling(liveDecidePlan), + {}, + { policy: { rules: { allowLocalDryRun: true } } }, + ); + + assert.strictEqual(status, 'success'); + assert.strictEqual(fixture.s1Executions(), 2); + const events = fixture.events; + const parkIndex = events.findIndex((event) => event.family === 'story.parked' && event.storyId === 's1'); + const grantIndex = events.findIndex( + (event) => + event.family === 'authorization.granted' && + event.storyId === 's1' && + Array.isArray(event.basis) && + event.basis.includes('owner-approval'), + ); + const doneIndex = events.findIndex((event) => event.family === 'story.done' && event.storyId === 's1'); + assert.ok(parkIndex >= 0 && grantIndex >= 0 && doneIndex >= 0); + assert.ok(parkIndex < grantIndex && grantIndex < doneIndex); + assert.strictEqual(events[grantIndex]?.requestId, 'REQ-1'); + assert.ok(events.find((event) => event.family === 'run.completed')); + assert.ok(!events.find((event) => event.family === 'run.stopped')); + // The durable decision already exists out of band; the harness must not re-record it. + assert.ok(!events.find((event) => event.family === 'owner-decision.recorded')); + assert.strictEqual(events.filter((event) => event.family === 'story.started' && event.storyId === 's1').length, 1); +}); + +test('P09-F2: a final sequential parked story consumes an out-of-band approval at its own park boundary', async () => { + const events: RunEvent[] = []; + let s1Executions = 0; + const harness = new LocalHarness( + { + execute: async () => { + s1Executions += 1; + if (s1Executions === 1) { + return { + storyId: 's1', + outcome: 'success', + evidence: { result: 'passed' }, + requests: [{ id: 'REQ-1', kind: 'edit-files', privileged: true }], + }; + } + return { storyId: 's1', outcome: 'success', evidence: { result: 'passed' } }; + }, + }, + { + init: () => {}, + recordEvent: (event: RunEvent) => events.push(event), + readEvents: () => [ + ...events, + { + family: 'owner-decision.recorded', + actor: 'owner', + storyId: 's1', + outcome: 'approve', + requestId: 'REQ-1', + requestKind: 'edit-files', + } as RunEvent, + ], + finalize: async () => {}, + }, + ); + const plan: PlanInstance = { + plan: { + id: 'p-single-live-decide', + version: 'execution-plan-shape-v0', + stories: [{ id: 's1', title: 'parks as the final sequential story' }], + }, + }; + + const status = await harness.run( + validatePlanForScheduling(plan), + {}, + { policy: { rules: { allowLocalDryRun: true } } }, + ); + + assert.strictEqual(status, 'success'); + assert.strictEqual(s1Executions, 2); + assert.ok( + events.find( + (event) => + event.family === 'authorization.granted' && + event.storyId === 's1' && + Array.isArray(event.basis) && + event.basis.includes('owner-approval'), + ), + ); + assert.ok(events.find((event) => event.family === 'story.done' && event.storyId === 's1')); + assert.ok(events.find((event) => event.family === 'run.completed')); + assert.ok(!events.find((event) => event.family === 'run.stopped')); +}); + +test('P09-F2: a live approved re-execution reuses the parked story workspace', async () => { + const events: RunEvent[] = []; + const seenWorkspacePaths: string[] = []; + let s1Executions = 0; + const harness = new LocalHarness( + { + execute: async (story: { id: string; workspace?: { path: string } }) => { + if (story.id === 's1') { + s1Executions += 1; + seenWorkspacePaths.push(story.workspace?.path ?? 'missing'); + if (s1Executions === 1) { + return { + storyId: 's1', + outcome: 'success', + evidence: { result: 'passed' }, + requests: [{ id: 'REQ-1', kind: 'edit-files', privileged: true }], + }; + } + } + return { storyId: story.id, outcome: 'success', evidence: { result: 'passed' } }; + }, + }, + { + init: () => {}, + recordEvent: (event: RunEvent) => events.push(event), + readEvents: () => [ + ...events, + { + family: 'owner-decision.recorded', + actor: 'owner', + storyId: 's1', + outcome: 'approve', + requestId: 'REQ-1', + requestKind: 'edit-files', + } as RunEvent, + ], + finalize: async () => {}, + }, + null, + { workspaceIsolation: createInMemoryStoryWorkspaceIsolation('/tmp/jig-live-decide-reuse') }, + ); + const plan: PlanInstance = { + plan: { + id: 'p-live-reuse-workspace', + version: 'execution-plan-shape-v0', + stories: [ + { id: 's1', title: 'parks on a routed decision' }, + { id: 's2', title: 'provides the next safe boundary' }, + ], + }, + }; + + const status = await harness.run( + validatePlanForScheduling(plan), + {}, + { policy: { rules: { allowLocalDryRun: true } } }, + ); + + assert.strictEqual(status, 'success'); + assert.strictEqual(s1Executions, 2); + assert.deepStrictEqual(seenWorkspacePaths, ['/tmp/jig-live-decide-reuse/s1', '/tmp/jig-live-decide-reuse/s1']); +}); + +test('P09-F2: a stop appended during approved live re-execution wins before completion is reported', async () => { + const events: RunEvent[] = []; + let s1Executions = 0; + const harness = new LocalHarness( + { + execute: async (story: { id: string }) => { + if (story.id === 's1') { + s1Executions += 1; + if (s1Executions === 1) { + return { + storyId: 's1', + outcome: 'success', + evidence: { result: 'passed' }, + requests: [{ id: 'REQ-1', kind: 'edit-files', privileged: true }], + }; + } + events.push({ + family: 'operator-action.requested', + actor: 'owner', + action: 'stop', + reason: 'owner-requested-pause', + } as RunEvent); + } + return { storyId: story.id, outcome: 'success', evidence: { result: 'passed' } }; + }, + }, + { + init: () => {}, + recordEvent: (event: RunEvent) => events.push(event), + readEvents: () => [ + ...events, + { + family: 'owner-decision.recorded', + actor: 'owner', + storyId: 's1', + outcome: 'approve', + requestId: 'REQ-1', + requestKind: 'edit-files', + } as RunEvent, + ], + finalize: async () => {}, + }, + ); + const plan: PlanInstance = { + plan: { + id: 'p-live-stop-after-reexec', + version: 'execution-plan-shape-v0', + stories: [{ id: 's1', title: 'parks as the final sequential story' }], + }, + }; + + const status = await harness.run( + validatePlanForScheduling(plan), + {}, + { policy: { rules: { allowLocalDryRun: true } } }, + ); + + assert.strictEqual(status, 'failure'); + assert.strictEqual(s1Executions, 2); + assert.ok(events.find((event) => event.family === 'authorization.granted' && event.storyId === 's1')); + assert.ok( + events.find( + (event) => + event.family === 'run.stopped' && event.reason === 'owner-requested-pause' && event.checkpoint === 'after:s1', + ), + ); + assert.ok(!events.find((event) => event.family === 'run.completed')); +}); + +test('P09-F2: a live out-of-band rejection blocks the parked story at the next safe boundary', async () => { + const fixture = liveParkedRunFixture([ + { + // An unknown outcome is ignored rather than consumed. + family: 'owner-decision.recorded', + actor: 'owner', + storyId: 's1', + outcome: 'defer', + }, + { + // No request identifiers on the decision: the park event supplies them. + family: 'owner-decision.recorded', + actor: 'owner', + storyId: 's1', + outcome: 'reject', + }, + ]); + + const status = await fixture.harness.run( + validatePlanForScheduling(liveDecidePlan), + {}, + { policy: { rules: { allowLocalDryRun: true } } }, + ); + + assert.strictEqual(status, 'failure'); + assert.strictEqual(fixture.s1Executions(), 1); + const events = fixture.events; + assert.ok( + events.find( + (event) => + event.family === 'authorization.denied' && + event.storyId === 's1' && + event.requestId === 'REQ-1' && + Array.isArray(event.basis) && + event.basis.includes('owner-rejection'), + ), + ); + assert.ok( + events.find( + (event) => event.family === 'story.blocked' && event.storyId === 's1' && event.reason === 'owner-rejection', + ), + ); + assert.ok( + events.find( + (event) => + event.family === 'run.stopped' && event.reason === 'work-item-blocked' && event.checkpoint === 'after:s1', + ), + ); +}); + +test('P09-F2: a live out-of-band hand-off records one re-target and keeps the story parked', async () => { + const fixture = liveParkedRunFixture([ + { + family: 'owner-decision.recorded', + actor: 'owner', + storyId: 's1', + outcome: 'hand-off', + requestId: 'REQ-1', + requestKind: 'edit-files', + handedOffTo: 'platform-owner', + }, + ]); + // A third story adds a later safe boundary: the consumed hand-off must not re-route there. + const plan: PlanInstance = { + plan: { + id: 'p1', + version: 'execution-plan-shape-v0', + stories: [ + { id: 's1', title: 'parks on a routed decision' }, + { id: 's2', title: 'keeps the run live' }, + { id: 's3', title: 'adds a later boundary' }, + ], + }, + }; + + const status = await fixture.harness.run( + validatePlanForScheduling(plan), + {}, + { policy: { rules: { allowLocalDryRun: true } } }, + ); + + assert.strictEqual(status, 'failure'); + assert.strictEqual(fixture.s1Executions(), 1); + const events = fixture.events; + const reroutes = events.filter( + (event) => + event.family === 'authorization.routed' && + event.storyId === 's1' && + event.requestId === 'REQ-1' && + Array.isArray(event.basis) && + event.basis.includes('owner-hand-off') && + event.handedOffTo === 'platform-owner', + ); + assert.strictEqual(reroutes.length, 1); + assert.ok(events.find((event) => event.family === 'story.done' && event.storyId === 's3')); + assert.ok(!events.find((event) => event.family === 'authorization.granted' && event.storyId === 's1')); + assert.ok( + events.find( + (event) => + event.family === 'run.stopped' && event.reason === 'unattended-park' && event.checkpoint === 'after:s1.parked', + ), + ); +}); + +test('P09-F2: a stop request and a pending decision at the same boundary resolve stop-first', async () => { + const fixture = liveParkedRunFixture([ + { + family: 'operator-action.requested', + actor: 'owner', + action: 'stop', + reason: 'owner-requested-pause', + }, + { + family: 'owner-decision.recorded', + actor: 'owner', + storyId: 's1', + outcome: 'approve', + requestId: 'REQ-1', + requestKind: 'edit-files', + }, + ]); + + const status = await fixture.harness.run( + validatePlanForScheduling(liveDecidePlan), + {}, + { policy: { rules: { allowLocalDryRun: true } } }, + ); + + assert.strictEqual(status, 'failure'); + assert.strictEqual(fixture.s1Executions(), 1); + const events = fixture.events; + // Stop-wins: the decision stays durable and unconsumed for resume to pick up. + assert.ok(!events.find((event) => event.family === 'authorization.granted' && event.storyId === 's1')); + assert.ok( + events.find( + (event) => + event.family === 'run.stopped' && event.reason === 'unattended-park' && event.checkpoint === 'after:s1.parked', + ), + ); +}); + +test('P09-F2: a consumed live decision is not re-applied when the story parks again', async () => { + const fixture = liveParkedRunFixture( + [ + { + family: 'owner-decision.recorded', + actor: 'owner', + storyId: 's1', + outcome: 'approve', + requestId: 'REQ-1', + requestKind: 'edit-files', + }, + ], + () => ({ + storyId: 's1', + outcome: 'success', + evidence: { result: 'passed' }, + requests: [{ id: 'REQ-2', kind: 'edit-files', privileged: true }], + }), + ); + + const status = await fixture.harness.run( + validatePlanForScheduling(liveDecidePlan), + {}, + { policy: { rules: { allowLocalDryRun: true } } }, + ); + + assert.strictEqual(status, 'failure'); + assert.strictEqual(fixture.s1Executions(), 2); + const events = fixture.events; + const grants = events.filter( + (event) => + event.family === 'authorization.granted' && + event.storyId === 's1' && + Array.isArray(event.basis) && + event.basis.includes('owner-approval'), + ); + assert.strictEqual(grants.length, 1); + assert.strictEqual(events.filter((event) => event.family === 'story.parked' && event.storyId === 's1').length, 2); + assert.ok( + events.find( + (event) => + event.family === 'run.stopped' && event.reason === 'unattended-park' && event.checkpoint === 'after:s1.parked', + ), + ); +}); + +test('P09-F2: an approved re-execution that blocks stops the run at the blocked story', async () => { + const fixture = liveParkedRunFixture( + [ + { + family: 'owner-decision.recorded', + actor: 'owner', + storyId: 's1', + outcome: 'approve', + requestId: 'REQ-1', + requestKind: 'edit-files', + }, + ], + () => ({ storyId: 's1', outcome: 'failure', evidence: { result: 'failed' } }), + ); + + const status = await fixture.harness.run( + validatePlanForScheduling(liveDecidePlan), + {}, + { policy: { rules: { allowLocalDryRun: true } } }, + ); + + assert.strictEqual(status, 'failure'); + assert.strictEqual(fixture.s1Executions(), 2); + const events = fixture.events; + assert.ok(events.find((event) => event.family === 'authorization.granted' && event.storyId === 's1')); + assert.ok( + events.find( + (event) => + event.family === 'story.blocked' && event.storyId === 's1' && event.reason === 'worker-reported-failure', + ), + ); + assert.ok( + events.find( + (event) => + event.family === 'run.stopped' && event.reason === 'work-item-blocked' && event.checkpoint === 'after:s1', + ), + ); +}); + +test('P09-F2: an approval consumed after an unrelated block keeps the blocked-run checkpoint honest', async () => { + const events: RunEvent[] = []; + let outOfBandIndex: number | null = null; + let s1Executions = 0; + const harness = new LocalHarness( + { + execute: async (story: { id: string }) => { + if (story.id === 's0') { + return { storyId: 's0', outcome: 'failure', evidence: { result: 'failed' } }; + } + if (story.id === 's1') { + s1Executions += 1; + if (s1Executions === 1) { + return { + storyId: 's1', + outcome: 'success', + evidence: { result: 'passed' }, + requests: [{ id: 'REQ-1', kind: 'edit-files', privileged: true }], + }; + } + return { storyId: 's1', outcome: 'success', evidence: { result: 'passed' } }; + } + outOfBandIndex = events.length; + return { storyId: story.id, outcome: 'success', evidence: { result: 'passed' } }; + }, + }, + { + init: () => {}, + recordEvent: (event: RunEvent) => events.push(event), + readEvents: () => + outOfBandIndex === null + ? [...events] + : [ + ...events.slice(0, outOfBandIndex), + { + family: 'owner-decision.recorded', + actor: 'owner', + storyId: 's1', + outcome: 'approve', + requestId: 'REQ-1', + requestKind: 'edit-files', + } as RunEvent, + ...events.slice(outOfBandIndex), + ], + finalize: async () => {}, + }, + ); + const plan: PlanInstance = { + plan: { + id: 'p1', + version: 'execution-plan-shape-v0', + stories: [ + { id: 's0', title: 'blocks first' }, + { id: 's1', title: 'parks on a routed decision' }, + { id: 's2', title: 'keeps the run live' }, + ], + }, + }; + + const status = await harness.run( + validatePlanForScheduling(plan), + {}, + { policy: { rules: { allowLocalDryRun: true, blockResolution: 'continue-independent-work' } } }, + ); + + assert.strictEqual(status, 'failure'); + assert.strictEqual(s1Executions, 2); + assert.ok(events.find((event) => event.family === 'story.done' && event.storyId === 's1')); + assert.ok( + events.find( + (event) => + event.family === 'run.stopped' && event.reason === 'work-item-blocked' && event.checkpoint === 'after:s1', + ), + ); +}); + +test('P09-F2: without a durable log reader a live parked run still stops unattended', async () => { + const events: RunEvent[] = []; + const harness = new LocalHarness( + { + execute: async (story: { id: string }) => { + if (story.id === 's1') { + return { + storyId: 's1', + outcome: 'success', + evidence: { result: 'passed' }, + requests: [{ id: 'REQ-1', kind: 'edit-files', privileged: true }], + }; + } + return { storyId: story.id, outcome: 'success', evidence: { result: 'passed' } }; + }, + }, + { + init: () => {}, + recordEvent: (event: RunEvent) => events.push(event), + finalize: async () => {}, + }, + ); + + const status = await harness.run( + validatePlanForScheduling(liveDecidePlan), + {}, + { policy: { rules: { allowLocalDryRun: true } } }, + ); + + assert.strictEqual(status, 'failure'); + assert.ok( + events.find( + (event) => + event.family === 'run.stopped' && event.reason === 'unattended-park' && event.checkpoint === 'after:s1.parked', + ), + ); +}); + +test('P09-F2: a batched live run consumes an out-of-band approval at the batch boundary', async () => { + const events: RunEvent[] = []; + let s1Executions = 0; + const harness = new LocalHarness( + { + execute: async (story: { id: string }) => { + if (story.id === 's1') { + s1Executions += 1; + if (s1Executions === 1) { + return { + storyId: 's1', + outcome: 'success', + evidence: { result: 'passed' }, + requests: [{ id: 'REQ-1', kind: 'edit-files', privileged: true }], + }; + } + return { storyId: 's1', outcome: 'success', evidence: { result: 'passed' } }; + } + return { storyId: story.id, outcome: 'success', evidence: { result: 'passed' } }; + }, + }, + { + init: () => {}, + recordEvent: (event: RunEvent) => events.push(event), + // The decide lands while the batch is executing; the durable log always orders it after + // the recorded park. + readEvents: () => [ + ...events, + { + family: 'owner-decision.recorded', + actor: 'owner', + storyId: 's1', + outcome: 'approve', + requestId: 'REQ-1', + requestKind: 'edit-files', + } as RunEvent, + ], + finalize: async () => {}, + }, + null, + { workspaceIsolation: createInMemoryStoryWorkspaceIsolation('/tmp/jig-live-decide-batch') }, + ); + const plan: PlanInstance = { + plan: { + id: 'p-batch', + version: 'execution-plan-shape-v0', + stories: [ + { id: 's1', title: 'parks in the batch' }, + { id: 's2', title: 'succeeds in the batch' }, + ], + }, + }; + + const status = await harness.run( + validatePlanForScheduling(plan), + {}, + { policy: { rules: { allowLocalDryRun: true, concurrencyCeiling: 2 } } }, + ); + + assert.strictEqual(status, 'success'); + assert.strictEqual(s1Executions, 2); + assert.ok( + events.find( + (event) => + event.family === 'authorization.granted' && + event.storyId === 's1' && + Array.isArray(event.basis) && + event.basis.includes('owner-approval'), + ), + ); + assert.ok(events.find((event) => event.family === 'story.done' && event.storyId === 's1')); + assert.ok(events.find((event) => event.family === 'run.completed')); + assert.ok(!events.find((event) => event.family === 'run.stopped')); + // The re-execution reuses the parked story's workspace instead of re-allocating. + assert.ok(!events.find((event) => event.family === 'story.blocked' && event.reason === 'workspace-collision')); +}); + +function batchedParkedHarness(outOfBandEvents: RunEvent[]): { events: RunEvent[]; harness: LocalHarness } { + const events: RunEvent[] = []; + const harness = new LocalHarness( + { + execute: async (story: { id: string }) => { + if (story.id === 's1') { + return { + storyId: 's1', + outcome: 'success', + evidence: { result: 'passed' }, + requests: [{ id: 'REQ-1', kind: 'edit-files', privileged: true }], + }; + } + return { storyId: story.id, outcome: 'success', evidence: { result: 'passed' } }; + }, + }, + { + init: () => {}, + recordEvent: (event: RunEvent) => events.push(event), + readEvents: () => [...events, ...outOfBandEvents], + finalize: async () => {}, + }, + null, + { workspaceIsolation: createInMemoryStoryWorkspaceIsolation('/tmp/jig-live-decide-batch') }, + ); + return { events, harness }; +} + +const batchedFourStoryPlan: PlanInstance = { + plan: { + id: 'p-batch-4', + version: 'execution-plan-shape-v0', + stories: [ + { id: 's1', title: 'parks in the first batch' }, + { id: 's2', title: 'succeeds in the first batch' }, + { id: 's3', title: 'second batch' }, + { id: 's4', title: 'second batch' }, + ], + }, +}; + +test('P09-F2: a batched stop request wins over a pending decision at the batch boundary', async () => { + const { events, harness } = batchedParkedHarness([ + { + family: 'operator-action.requested', + actor: 'owner', + action: 'stop', + reason: 'owner-requested-pause', + }, + { + family: 'owner-decision.recorded', + actor: 'owner', + storyId: 's1', + outcome: 'approve', + requestId: 'REQ-1', + requestKind: 'edit-files', + }, + ]); + + const status = await harness.run( + validatePlanForScheduling(batchedFourStoryPlan), + {}, + { policy: { rules: { allowLocalDryRun: true, concurrencyCeiling: 2 } } }, + ); + + assert.strictEqual(status, 'failure'); + // Stop-wins: the decision stays durable and unconsumed for resume. + assert.ok(!events.find((event) => event.family === 'authorization.granted' && event.storyId === 's1')); + assert.ok( + events.find( + (event) => + event.family === 'run.stopped' && + event.reason === 'unattended-park' && + event.checkpoint === 'after:s1.parked' && + Array.isArray(event.unstarted) && + event.unstarted.includes('s3') && + event.unstarted.includes('s4'), + ), + ); +}); + +test('P09-F2: a batched rejection blocks the parked story and quarantines the remaining batches', async () => { + const { events, harness } = batchedParkedHarness([ + { + family: 'owner-decision.recorded', + actor: 'owner', + storyId: 's1', + outcome: 'reject', + requestId: 'REQ-1', + requestKind: 'edit-files', + }, + ]); + + const status = await harness.run( + validatePlanForScheduling(batchedFourStoryPlan), + {}, + { policy: { rules: { allowLocalDryRun: true, concurrencyCeiling: 2 } } }, + ); + + assert.strictEqual(status, 'failure'); + assert.ok( + events.find( + (event) => + event.family === 'authorization.denied' && + event.storyId === 's1' && + Array.isArray(event.basis) && + event.basis.includes('owner-rejection'), + ), + ); + assert.ok( + events.find( + (event) => event.family === 'story.blocked' && event.storyId === 's1' && event.reason === 'owner-rejection', + ), + ); + assert.ok( + events.find( + (event) => + event.family === 'run.stopped' && + event.reason === 'work-item-blocked' && + event.checkpoint === 'after:s1' && + Array.isArray(event.unstarted) && + event.unstarted.includes('s3') && + event.unstarted.includes('s4'), + ), + ); +}); + +test('P09-F2/P09-AC-5: an out-of-band decide against the live record is consumed and matches the interactive record shape', async () => { + const workDir = mkdtempSync(join(tmpdir(), 'jig-live-decide-')); + const recordDir = join(workDir, 'runs'); + const policy: PolicyDoc = { policy: { rules: { allowLocalDryRun: true } } }; + try { + const config: ConfigDoc = { runner: { mode: 'local-dry-run', recordDir } }; + let s1Executions = 0; + const decideResults: DecideRunResult[] = []; + const harness = new LocalHarness( + { + execute: async (story: { id: string }) => { + if (story.id === 's1') { + s1Executions += 1; + if (s1Executions === 1) { + return { + storyId: 's1', + outcome: 'success', + evidence: { result: 'passed' }, + requests: [{ id: 'REQ-1', kind: 'edit-files', privileged: true }], + }; + } + return { storyId: 's1', outcome: 'success', evidence: { result: 'passed' } }; + } + const [runName] = readdirSync(recordDir); + assert.ok(runName, 'expected the live run directory to exist'); + decideResults.push( + await createJigSession().operator.decide({ + runDir: join(recordDir, runName), + outcome: 'approve', + }), + ); + return { storyId: story.id, outcome: 'success', evidence: { result: 'passed' } }; + }, + }, + new RecordManager(), + ); + const plan: PlanInstance = { + plan: { + id: 'p-live-decide', + version: 'execution-plan-shape-v0', + stories: [ + { id: 's1', title: 'parks on a routed decision' }, + { id: 's2', title: 'decides out of band while live' }, + ], + }, + }; + + const status = await harness.run(validatePlanForScheduling(plan), config, policy); + + assert.strictEqual(status, 'success'); + assert.strictEqual(s1Executions, 2); + assert.strictEqual(decideResults.length, 1); + assert.strictEqual(decideResults[0]?.outcome, 'approve'); + assert.strictEqual(decideResults[0]?.storyId, 's1'); + + const [runName] = readdirSync(recordDir); + assert.ok(runName); + const recorded = readFileSync(join(recordDir, runName, 'events.jsonl'), 'utf8') + .trim() + .split('\n') + .map((line) => JSON.parse(line) as RunEvent); + const decision = recorded.find((event) => event.family === 'owner-decision.recorded'); + assert.ok(decision, 'expected the out-of-band decision in the durable record'); + assert.strictEqual(decision.actor, 'owner'); + assert.strictEqual(decision.storyId, 's1'); + assert.strictEqual(decision.outcome, 'approve'); + assert.strictEqual(decision.requestId, 'REQ-1'); + assert.strictEqual(decision.requestKind, 'edit-files'); + const decisionIndex = recorded.indexOf(decision); + const grantIndex = recorded.findIndex( + (event) => + event.family === 'authorization.granted' && + event.storyId === 's1' && + Array.isArray(event.basis) && + event.basis.includes('owner-approval'), + ); + assert.ok(decisionIndex < grantIndex, 'the decision must precede its recorded consumption'); + assert.ok(recorded.find((event) => event.family === 'run.completed')); + assert.ok(!recorded.find((event) => event.family === 'run.stopped')); + assert.ok(!recorded.find((event) => event.family === 'owner-decision.refused')); + + // Interactive control: the in-process prompt answering the same routed request must record + // an identically shaped owner-decision event (P09-AC-5 parity across surfaces). + const interactiveDir = join(workDir, 'interactive-runs'); + const interactiveHarness = new LocalHarness( + { + execute: async (story: { id: string }) => ({ + storyId: story.id, + outcome: 'success', + evidence: { result: 'passed' }, + requests: [{ id: 'REQ-1', kind: 'edit-files', privileged: true }], + }), + }, + new RecordManager(), + { decide: async () => 'approve' }, + ); + const interactivePlan: PlanInstance = { + plan: { + id: 'p-interactive-decide', + version: 'execution-plan-shape-v0', + stories: [{ id: 's1', title: 'answers interactively' }], + }, + }; + const interactiveStatus = await interactiveHarness.run( + validatePlanForScheduling(interactivePlan), + { runner: { mode: 'local-dry-run', recordDir: interactiveDir } }, + policy, + ); + assert.strictEqual(interactiveStatus, 'success'); + const [interactiveRunName] = readdirSync(interactiveDir); + assert.ok(interactiveRunName); + const interactiveEvents = readFileSync(join(interactiveDir, interactiveRunName, 'events.jsonl'), 'utf8') + .trim() + .split('\n') + .map((line) => JSON.parse(line) as RunEvent); + const interactiveDecision = interactiveEvents.find((event) => event.family === 'owner-decision.recorded'); + assert.ok(interactiveDecision, 'expected the interactive decision in the durable record'); + assert.deepStrictEqual(Object.keys(decision).sort(), Object.keys(interactiveDecision).sort()); + assert.strictEqual(decision.requestId, interactiveDecision.requestId); + assert.strictEqual(decision.requestKind, interactiveDecision.requestKind); + assert.strictEqual(decision.outcome, interactiveDecision.outcome); + } finally { + rmSync(workDir, { recursive: true, force: true }); + } +}); + test('P3/P4: harness records grant, deny, routed approval, missing evidence, and failed evidence branches', async () => { const policy: PolicyDoc = { policy: { rules: { allowLocalDryRun: true } } }; diff --git a/packages/jig-sdk/tests/projection.unit.test.ts b/packages/jig-sdk/tests/projection.unit.test.ts index dfa28a2..da46290 100644 --- a/packages/jig-sdk/tests/projection.unit.test.ts +++ b/packages/jig-sdk/tests/projection.unit.test.ts @@ -1309,3 +1309,150 @@ test('P08-AC-3: blocked PR surfacing records remain explainable citations', () = assert.match(why.answer, /blocked because pr-surfacing-failed/); assert.ok(why.citations.some((citation) => citation.family === 'runner-action.opened-pr')); }); + +function liveParkedEvents(): RunEvent[] { + return [ + launchHeader(), + { + family: 'story.started', + actor: 'runner', + timestamp: '2026-07-02T10:00:01.000Z', + storyId: 'STORY-1', + }, + { + family: 'authorization.routed', + actor: 'runner', + timestamp: '2026-07-02T10:00:02.000Z', + storyId: 'STORY-1', + requestId: 'REQ-1', + requestKind: 'edit-files', + basis: ['fence-routed'], + }, + { + family: 'story.parked', + actor: 'runner', + timestamp: '2026-07-02T10:00:03.000Z', + storyId: 'STORY-1', + requestId: 'REQ-1', + reason: 'owner-decision-required', + }, + ]; +} + +test('P09-F2: a live parked run exposes the routed-decision surface without a safe checkpoint', () => { + const projection = projectRunEvents({ eventsJsonl: stringifyJsonl(liveParkedEvents()) }); + + assert.strictEqual(projection.lifecycleState, 'started'); + assert.strictEqual(projection.safeCheckpoint, undefined); + // The park omits the request kind; the routed record supplies it. + assert.deepStrictEqual(projection.routedDecision, { + storyId: 'STORY-1', + requestId: 'REQ-1', + requestKind: 'edit-files', + }); +}); + +test('P09-F2: the stopped-run routed-decision surface matches the parked safe checkpoint', () => { + const projection = projectRunEvents({ eventsJsonl: stringifyJsonl(stoppedRunEvents()) }); + + assert.strictEqual(projection.safeCheckpoint, 'after:STORY-2.parked'); + assert.deepStrictEqual(projection.routedDecision, { + storyId: 'STORY-2', + requestId: 'REQ-2', + requestKind: 'edit-rule-governing-file', + }); +}); + +test('P09-F2: completed, unparked, and granted-after-park runs expose no routed decision', () => { + const completed = projectRunEvents({ + eventsJsonl: stringifyJsonl([ + launchHeader(), + { family: 'run.completed', actor: 'runner', timestamp: '2026-07-02T10:00:01.000Z' }, + ]), + }); + assert.strictEqual(completed.routedDecision, undefined); + + const unparked = projectRunEvents({ + eventsJsonl: stringifyJsonl([ + launchHeader(), + { family: 'story.started', actor: 'runner', timestamp: '2026-07-02T10:00:01.000Z', storyId: 'STORY-1' }, + ]), + }); + assert.strictEqual(unparked.routedDecision, undefined); + + const granted = projectRunEvents({ + eventsJsonl: stringifyJsonl([ + ...liveParkedEvents(), + { + family: 'owner-decision.recorded', + actor: 'owner', + timestamp: '2026-07-02T10:00:04.000Z', + storyId: 'STORY-1', + outcome: 'approve', + requestId: 'REQ-1', + requestKind: 'edit-files', + }, + { + family: 'authorization.granted', + actor: 'runner', + timestamp: '2026-07-02T10:00:05.000Z', + storyId: 'STORY-1', + requestId: 'REQ-1', + requestKind: 'edit-files', + basis: ['owner-approval'], + }, + ]), + }); + assert.strictEqual(granted.routedDecision, undefined); + assert.strictEqual(granted.stories['STORY-1']?.state, 'started'); +}); + +test('P09-F2: the latest still-parked story owns the live routed-decision surface', () => { + const projection = projectRunEvents({ + eventsJsonl: stringifyJsonl([ + ...liveParkedEvents(), + { family: 'story.started', actor: 'runner', timestamp: '2026-07-02T10:00:04.000Z', storyId: 'STORY-2' }, + { + family: 'authorization.routed', + actor: 'runner', + timestamp: '2026-07-02T10:00:05.000Z', + storyId: 'STORY-2', + requestId: 'REQ-2', + requestKind: 'edit-files', + basis: ['fence-routed'], + }, + { + family: 'story.parked', + actor: 'runner', + timestamp: '2026-07-02T10:00:06.000Z', + storyId: 'STORY-2', + requestId: 'REQ-2', + reason: 'owner-decision-required', + }, + ]), + }); + + assert.strictEqual(projection.routedDecision?.storyId, 'STORY-2'); + assert.strictEqual(projection.routedDecision?.requestId, 'REQ-2'); +}); + +test('P09-F2: a hand-off routed record against a parked story replays legally', () => { + const projection = projectRunEvents({ + eventsJsonl: stringifyJsonl([ + ...liveParkedEvents(), + { + family: 'authorization.routed', + actor: 'runner', + timestamp: '2026-07-02T10:00:04.000Z', + storyId: 'STORY-1', + requestId: 'REQ-1', + requestKind: 'edit-files', + basis: ['owner-hand-off'], + handedOffTo: 'platform-owner', + }, + ]), + }); + + assert.strictEqual(projection.stories['STORY-1']?.state, 'parked'); + assert.strictEqual(projection.stories['STORY-1']?.lastEventFamily, 'authorization.routed'); +}); diff --git a/packages/jig-sdk/tests/resume.unit.test.ts b/packages/jig-sdk/tests/resume.unit.test.ts index b2d7dde..49bdc0c 100644 --- a/packages/jig-sdk/tests/resume.unit.test.ts +++ b/packages/jig-sdk/tests/resume.unit.test.ts @@ -1306,3 +1306,164 @@ test('P09-AC-1/3: ResumePlan carries the latest durable owner decision for the p handedOffTo: 'platform-owner', }); }); + +function parkedStopProjection(): RunProjection { + return { + runId: 'run-existing', + planId: 'plan-resume', + mode: 'local-dry-run', + binding: { + policyRef: 'local-dry-run-policy', + configRef: 'mode=local-dry-run;recordDir=runs', + workspace: { + repoRoot: '/tmp/jig', + head: 'abc', + changeSetHash: 'clean', + }, + }, + workspace: { + repoRoot: '/tmp/jig', + head: 'abc', + changeSetHash: 'clean', + }, + posture: { + record: 'safe-for-owner-record', + export: 'redacted', + }, + planSnapshotRef: 'plan.snapshot.json', + policySnapshotRef: 'policy.snapshot.json', + status: 'failure', + lifecycleState: 'stopped', + stopCause: 'unattended-park', + safeCheckpoint: 'after:STORY-2.parked', + unstartedStoryIds: [], + stories: { + 'STORY-2': { storyId: 'STORY-2', state: 'parked', changedFiles: [], lastEventFamily: 'story.parked' }, + }, + changedFiles: [], + diagnostics: [], + notices: [], + eventCount: 6, + }; +} + +test('P09-F2: a decision consumed live at a safe boundary is not re-applied by resume', () => { + const resumePlan = buildResumePlan(parkedStopProjection(), [ + { + family: 'story.parked', + actor: 'runner', + storyId: 'STORY-2', + requestId: 'REQ-1', + requestKind: 'edit-files', + }, + { + family: 'owner-decision.recorded', + actor: 'owner', + storyId: 'STORY-2', + outcome: 'approve', + requestId: 'REQ-1', + requestKind: 'edit-files', + }, + { + family: 'authorization.granted', + actor: 'runner', + storyId: 'STORY-2', + requestId: 'REQ-1', + requestKind: 'edit-files', + basis: ['owner-approval'], + }, + { + family: 'story.parked', + actor: 'runner', + storyId: 'STORY-2', + requestId: 'REQ-2', + requestKind: 'edit-files', + }, + ]); + + assert.strictEqual(resumePlan.parkedDecision, undefined); +}); + +test('P09-F2: a decision recorded before the story ever parked is not treated as pending', () => { + const resumePlan = buildResumePlan(parkedStopProjection(), [ + { + family: 'owner-decision.recorded', + actor: 'owner', + storyId: 'STORY-2', + outcome: 'approve', + requestId: 'REQ-1', + requestKind: 'edit-files', + }, + { + family: 'story.parked', + actor: 'runner', + storyId: 'STORY-2', + requestId: 'REQ-1', + requestKind: 'edit-files', + }, + ]); + + assert.strictEqual(resumePlan.parkedDecision, undefined); +}); + +test('P09-F2: a live-consumed hand-off is not returned as a pending resume decision', () => { + const resumePlan = buildResumePlan(parkedStopProjection(), [ + { + family: 'story.parked', + actor: 'runner', + storyId: 'STORY-2', + requestId: 'REQ-1', + requestKind: 'edit-files', + }, + { + family: 'owner-decision.recorded', + actor: 'owner', + storyId: 'STORY-2', + outcome: 'hand-off', + requestId: 'REQ-1', + requestKind: 'edit-files', + handedOffTo: 'platform-owner', + }, + { + family: 'authorization.routed', + actor: 'runner', + storyId: 'STORY-2', + requestId: 'REQ-1', + requestKind: 'edit-files', + basis: ['owner-hand-off'], + handedOffTo: 'platform-owner', + }, + ]); + + assert.strictEqual(resumePlan.parkedDecision, undefined); +}); + +test('P09-F2: a rejection consumed live is not re-applied by resume', () => { + const resumePlan = buildResumePlan(parkedStopProjection(), [ + { + family: 'story.parked', + actor: 'runner', + storyId: 'STORY-2', + requestId: 'REQ-1', + requestKind: 'edit-files', + }, + { + family: 'owner-decision.recorded', + actor: 'owner', + storyId: 'STORY-2', + outcome: 'reject', + requestId: 'REQ-1', + requestKind: 'edit-files', + }, + { + family: 'authorization.denied', + actor: 'runner', + storyId: 'STORY-2', + requestId: 'REQ-1', + requestKind: 'edit-files', + basis: ['owner-rejection'], + }, + ]); + + assert.strictEqual(resumePlan.parkedDecision, undefined); +}); diff --git a/packages/jig-sdk/tests/sdk.unit.test.ts b/packages/jig-sdk/tests/sdk.unit.test.ts index d71d6d8..50d981a 100644 --- a/packages/jig-sdk/tests/sdk.unit.test.ts +++ b/packages/jig-sdk/tests/sdk.unit.test.ts @@ -1584,6 +1584,164 @@ test('P09-AC-3: operator decide records refusals when no routed decision exists' assert.strictEqual(result.reason, 'no-routed-decision'); }); +// A live run: STORY-2 is parked on a routed request and no terminal run.stopped exists yet. +function writeLiveParkedRun(name = 'live-parked-run'): string { + const runDir = join(workDir, name); + mkdirSync(runDir, { recursive: true }); + writeFileSync( + join(runDir, 'events.jsonl'), + stringifyJsonl([ + phase4LaunchHeader(), + { + family: 'story.started', + actor: 'runner', + timestamp: '2026-07-03T09:00:01.000Z', + storyId: 'STORY-1', + }, + { + family: 'story.done', + actor: 'runner', + timestamp: '2026-07-03T09:00:02.000Z', + storyId: 'STORY-1', + }, + { + family: 'story.started', + actor: 'runner', + timestamp: '2026-07-03T09:00:03.000Z', + storyId: 'STORY-2', + }, + { + family: 'authorization.routed', + actor: 'runner', + timestamp: '2026-07-03T09:00:04.000Z', + storyId: 'STORY-2', + requestId: 'REQ-2', + requestKind: 'edit-files', + basis: ['fence-routed'], + }, + { + family: 'story.parked', + actor: 'runner', + timestamp: '2026-07-03T09:00:05.000Z', + storyId: 'STORY-2', + requestId: 'REQ-2', + reason: 'owner-decision-required', + }, + ]), + ); + return runDir; +} + +test('P09-F2: decide routes a live parked run instead of refusing', async () => { + const session = createJigSession(); + const runDir = writeLiveParkedRun(); + + const result = await session.operator.decide({ runDir, outcome: 'approve' }); + + assert.deepStrictEqual(result, { + runDir, + outcome: 'approve', + storyId: 'STORY-2', + reason: undefined, + }); + + const events = readFileSync(join(runDir, 'events.jsonl'), 'utf8') + .trim() + .split('\n') + .map((line) => JSON.parse(line) as RunEvent); + const decision = events.find((event) => event.family === 'owner-decision.recorded'); + assert.ok(decision); + assert.strictEqual(decision.actor, 'owner'); + assert.strictEqual(decision.storyId, 'STORY-2'); + assert.strictEqual(decision.outcome, 'approve'); + assert.strictEqual(decision.requestId, 'REQ-2'); + // The park event omits the request kind; the routed record supplies it so the out-of-band + // decision record matches the interactive record shape. + assert.strictEqual(decision.requestKind, 'edit-files'); + assert.ok(!events.find((event) => event.family === 'owner-decision.refused')); +}); + +test('P09-F2: live decide records reject and hand-off outcomes with their targets', async () => { + const session = createJigSession(); + + const rejectRun = writeLiveParkedRun('live-reject-run'); + const rejected = await session.operator.decide({ runDir: rejectRun, outcome: 'reject' }); + assert.strictEqual(rejected.outcome, 'reject'); + assert.strictEqual(rejected.storyId, 'STORY-2'); + + const handoffRun = writeLiveParkedRun('live-handoff-run'); + const handedOff = await session.operator.decide({ + runDir: handoffRun, + outcome: 'hand-off', + handedOffTo: 'platform-owner', + }); + assert.strictEqual(handedOff.outcome, 'hand-off'); + const events = readFileSync(join(handoffRun, 'events.jsonl'), 'utf8') + .trim() + .split('\n') + .map((line) => JSON.parse(line) as RunEvent); + assert.ok( + events.find( + (event) => + event.family === 'owner-decision.recorded' && + event.outcome === 'hand-off' && + event.handedOffTo === 'platform-owner', + ), + ); +}); + +test('P09-F2: duplicate live decide refuses decision-already-recorded', async () => { + const session = createJigSession(); + const runDir = writeLiveParkedRun('live-duplicate-run'); + + await session.operator.decide({ runDir, outcome: 'approve' }); + const duplicate = await session.operator.decide({ runDir, outcome: 'reject' }); + + assert.strictEqual(duplicate.outcome, 'refused'); + assert.strictEqual(duplicate.reason, 'decision-already-recorded'); +}); + +test('P09-F2: live decide refuses out-of-scope stories and missing hand-off targets', async () => { + const session = createJigSession(); + + const outOfScopeRun = writeLiveParkedRun('live-out-of-scope-run'); + const outOfScope = await session.operator.decide({ + runDir: outOfScopeRun, + outcome: 'approve', + storyId: 'STORY-404', + }); + assert.strictEqual(outOfScope.outcome, 'refused'); + assert.strictEqual(outOfScope.reason, 'decision-outside-routed-scope'); + + const missingTargetRun = writeLiveParkedRun('live-missing-target-run'); + const missingTarget = await session.operator.decide({ runDir: missingTargetRun, outcome: 'hand-off' }); + assert.strictEqual(missingTarget.outcome, 'refused'); + assert.strictEqual(missingTarget.reason, 'missing-hand-off-target'); +}); + +test('P09-F2/AC-3: live decide against a run with no parked story still refuses no-routed-decision', async () => { + const session = createJigSession(); + const runDir = join(workDir, 'live-unparked-run'); + mkdirSync(runDir, { recursive: true }); + writeFileSync( + join(runDir, 'events.jsonl'), + stringifyJsonl([ + phase4LaunchHeader(), + { + family: 'story.started', + actor: 'runner', + timestamp: '2026-07-03T09:00:01.000Z', + storyId: 'STORY-1', + }, + ]), + ); + + const result = await session.operator.decide({ runDir, outcome: 'approve' }); + + assert.strictEqual(result.outcome, 'refused'); + assert.strictEqual(result.reason, 'no-routed-decision'); +}); + test('P09-AC-1/2: operator decide extends records integrity sidecar when present', async () => { const session = createJigSession(); const runDir = writeObservationRun('integrity-decision-run');