@atolis-hq/wake 0.2.59 → 0.2.61

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  import { parseClaudePrintResult, parseRunnerResult } from '../../domain/schema.js';
2
2
  import { runAgentCliCommand } from '../runner/cli-command.js';
3
- import { buildStagePrompt } from '../runner/stage-prompt.js';
3
+ import { buildStagePrompt, sentinelListForApproval } from '../runner/stage-prompt.js';
4
4
  import { emitRuntimeEvent, runnerRuntimeEvent } from '../runner/runtime-events.js';
5
5
  import { writeRunnerTranscript } from '../runner/transcripts.js';
6
6
  import { createAgentExecution } from '../../core/live-execution.js';
@@ -120,6 +120,80 @@ function extractTokenUsage(parsed) {
120
120
  };
121
121
  }
122
122
  const CLAUDE_CLI_NAME = 'Claude';
123
+ // Bounded — this is a single "just tell me the status" follow-up, not a
124
+ // second attempt at the task, so it never needs more than one turn.
125
+ const ENVELOPE_REPAIR_MAX_TURNS = 1;
126
+ const ENVELOPE_REPAIR_TIMEOUT_MS = 60_000;
127
+ function buildEnvelopeRepairPrompt(skipApproval) {
128
+ return [
129
+ 'Your previous reply did not end with the required `wake-result` envelope, so it could not be parsed.',
130
+ 'Reply with ONLY a fenced `wake-result` JSON block containing a `status` field, then repeat that status word on its own line after the closing fence.',
131
+ `The status must be exactly one of: ${sentinelListForApproval(skipApproval)}, reflecting the outcome of your previous turn.`,
132
+ 'Do not repeat, summarize, or redo any of your previous work — this reply is parsed automatically and anything besides the envelope is discarded.',
133
+ ].join('\n');
134
+ }
135
+ function mergeTokenUsage(base, extra) {
136
+ if (extra === undefined) {
137
+ return base;
138
+ }
139
+ if (base === undefined) {
140
+ return extra;
141
+ }
142
+ return {
143
+ inputTokens: base.inputTokens + extra.inputTokens,
144
+ outputTokens: base.outputTokens + extra.outputTokens,
145
+ ...(base.cacheCreationInputTokens === undefined && extra.cacheCreationInputTokens === undefined
146
+ ? {}
147
+ : {
148
+ cacheCreationInputTokens: (base.cacheCreationInputTokens ?? 0) + (extra.cacheCreationInputTokens ?? 0),
149
+ }),
150
+ ...(base.cacheReadInputTokens === undefined && extra.cacheReadInputTokens === undefined
151
+ ? {}
152
+ : {
153
+ cacheReadInputTokens: (base.cacheReadInputTokens ?? 0) + (extra.cacheReadInputTokens ?? 0),
154
+ }),
155
+ ...(base.costUsd === undefined && extra.costUsd === undefined
156
+ ? {}
157
+ : { costUsd: (base.costUsd ?? 0) + (extra.costUsd ?? 0) }),
158
+ ...(base.turns === undefined && extra.turns === undefined
159
+ ? {}
160
+ : { turns: (base.turns ?? 0) + (extra.turns ?? 0) }),
161
+ };
162
+ }
163
+ // Asks the same (still-live) session to restate just its result envelope,
164
+ // for the case where a run otherwise completed but the model forgot the
165
+ // mandatory trailer — cheaper and more honest than defaulting the whole run
166
+ // to BLOCKED because of a formatting slip. Returns undefined on any failure
167
+ // so the caller falls back to the original (unparseable) result untouched.
168
+ async function attemptEnvelopeRepair(input) {
169
+ const args = buildClaudePrintArgs({
170
+ model: input.model,
171
+ prompt: buildEnvelopeRepairPrompt(input.skipApproval),
172
+ sessionName: input.sessionName,
173
+ resumeSessionId: input.sessionId,
174
+ maxTurns: ENVELOPE_REPAIR_MAX_TURNS,
175
+ });
176
+ const result = await runClaudeCommand({
177
+ command: input.command,
178
+ args,
179
+ cwd: input.cwd,
180
+ timeoutMs: input.timeoutMs,
181
+ });
182
+ if (result.exitCode !== 0 || result.timedOut || result.stdout.trim().length === 0) {
183
+ return undefined;
184
+ }
185
+ try {
186
+ const parsed = parseClaudePrintOutput(result.stdout);
187
+ const repairTokenUsage = extractTokenUsage(parsed);
188
+ return {
189
+ text: parsed.result,
190
+ ...(repairTokenUsage === undefined ? {} : { tokenUsage: repairTokenUsage }),
191
+ };
192
+ }
193
+ catch {
194
+ return undefined;
195
+ }
196
+ }
123
197
  export function classifyClaudeCliFailure(input) {
124
198
  if (input.timedOut) {
125
199
  return 'infra';
@@ -341,6 +415,30 @@ export function createClaudeRunner(options) {
341
415
  ...(parsed.session_id === undefined ? {} : { sessionId: parsed.session_id }),
342
416
  }));
343
417
  const tokenUsage = extractTokenUsage(parsed);
418
+ let effectiveResultText = parsed.result;
419
+ let effectiveTokenUsage = tokenUsage;
420
+ let envelopeRepaired = false;
421
+ if (parseRunnerResult(effectiveResultText).envelope === 'missing' &&
422
+ parsed.session_id !== undefined) {
423
+ const repair = await attemptEnvelopeRepair({
424
+ command: options.command,
425
+ cwd: input.workspacePath ?? options.cwd,
426
+ model,
427
+ sessionName,
428
+ sessionId: parsed.session_id,
429
+ skipApproval: stagePrompt.skipApproval,
430
+ timeoutMs: Math.min(options.settings.timeoutMs, ENVELOPE_REPAIR_TIMEOUT_MS),
431
+ });
432
+ if (repair !== undefined && parseRunnerResult(repair.text).envelope !== 'missing') {
433
+ effectiveResultText = `${effectiveResultText.trimEnd()}\n\n${repair.text.trim()}`;
434
+ effectiveTokenUsage = mergeTokenUsage(effectiveTokenUsage, repair.tokenUsage);
435
+ envelopeRepaired = true;
436
+ console.log(`[claude-run] envelope repair succeeded runId=${input.runId} sessionId=${parsed.session_id}`);
437
+ }
438
+ else {
439
+ console.error(`[claude-run] envelope repair failed runId=${input.runId} sessionId=${parsed.session_id}`);
440
+ }
441
+ }
344
442
  if (tokenUsage !== undefined) {
345
443
  await emitRuntimeEvent(input.onRuntimeEvent, runnerRuntimeEvent({
346
444
  type: 'agent.usage.updated',
@@ -364,20 +462,21 @@ export function createClaudeRunner(options) {
364
462
  payload: { exitCode: result.exitCode, timedOut: result.timedOut },
365
463
  }));
366
464
  return {
367
- result: parsed.result,
465
+ result: effectiveResultText,
368
466
  model,
369
467
  cli: CLAUDE_CLI_NAME,
370
- ...(parseRunnerResult(parsed.result).status === 'FAILED'
468
+ ...(parseRunnerResult(effectiveResultText).status === 'FAILED'
371
469
  ? { failureClass: 'task' }
372
470
  : {}),
373
471
  ...(parsed.session_id === undefined ? {} : { session_id: parsed.session_id }),
374
- ...(tokenUsage === undefined ? {} : { tokenUsage }),
472
+ ...(effectiveTokenUsage === undefined ? {} : { tokenUsage: effectiveTokenUsage }),
375
473
  metadata: {
376
474
  stdout: result.stdout,
377
475
  stderr: result.stderr,
378
476
  raw: parsed,
379
477
  skipApproval: stagePrompt.skipApproval,
380
478
  allowAutoApproval: stagePrompt.allowAutoApproval,
479
+ ...(envelopeRepaired ? { envelopeRepaired: true } : {}),
381
480
  ...(promptTranscriptPath === undefined ? {} : { promptTranscriptPath }),
382
481
  ...(responseTranscriptPath === undefined ? {} : { responseTranscriptPath }),
383
482
  ...(sandboxLog?.metadata ?? {}),
@@ -2,7 +2,7 @@ import { setTimeout as delay } from 'node:timers/promises';
2
2
  import { access, appendFile, mkdir, readFile, readdir, rename } from 'node:fs/promises';
3
3
  import { dirname, join } from 'node:path';
4
4
  import { validateResourceIndex } from './resource-index.js';
5
- import { parseEventEnvelope, parseIssueStateRecord, parseLedger, parseRunRecord, parseSourceStateRecord, } from '../../domain/schema.js';
5
+ import { parseEventEnvelope, parseIssueStateRecord, parseLedger, parseRunInputSnapshot, parseRunRecord, parseSourceStateRecord, } from '../../domain/schema.js';
6
6
  import { isTerminalStage } from '../../domain/stages.js';
7
7
  import { appendJsonLine, readJsonFile, writeJsonFile } from '../../lib/json-file.js';
8
8
  import { acquireFileLock } from '../../lib/lock.js';
@@ -509,6 +509,22 @@ export function createStateStore({ wakeRoot }) {
509
509
  await upsertRunSummaryIndexEntry(paths, parsed);
510
510
  return parsed;
511
511
  },
512
+ async writeRunInputSnapshot(record) {
513
+ const parsed = parseRunInputSnapshot(record);
514
+ await writeJsonFile(paths.runInputSnapshotFile(parsed.snapshotId), parsed);
515
+ return parsed;
516
+ },
517
+ async readRunInputSnapshot(snapshotId) {
518
+ try {
519
+ return parseRunInputSnapshot(await readJsonFile(paths.runInputSnapshotFile(snapshotId)));
520
+ }
521
+ catch (error) {
522
+ if (isMissingPathError(error)) {
523
+ return null;
524
+ }
525
+ throw error;
526
+ }
527
+ },
512
528
  async updateRunRecordIf(runId, input) {
513
529
  const current = await this.readRunRecord(runId);
514
530
  if (current === null || !input.expect(current)) {
@@ -82,7 +82,7 @@ function parseFrontmatterMaxTurns(input) {
82
82
  }
83
83
  return parsed;
84
84
  }
85
- function sentinelListForApproval(skipApproval) {
85
+ export function sentinelListForApproval(skipApproval) {
86
86
  return skipApproval ? 'DONE, BLOCKED, FAILED' : 'AWAITING_APPROVAL, BLOCKED, FAILED';
87
87
  }
88
88
  function sentinelInstructionsForApproval(skipApproval) {
@@ -121,7 +121,7 @@ function buildHarnessPrompt(input) {
121
121
  if (input.upstreamChanges !== undefined && input.upstreamChanges.trim().length > 0) {
122
122
  lines.push('', 'Upstream update notice:', 'Before resuming this session, Wake pulled the latest default-branch changes into your workspace. New commits included:', input.upstreamChanges.trimEnd());
123
123
  }
124
- lines.push('', 'Result envelope ABI:', 'Respond concisely. End your response with a fenced `wake-result` JSON block, then on its own line after the closing fence repeat the status word for degraded-mode fallback.', `The JSON \`status\` and final line must be exactly one of: ${sentinelListForApproval(input.skipApproval)}.`, sentinelInstructionsForApproval(input.skipApproval), 'The JSON object must contain only the `status` field. Do not add other fields.');
124
+ lines.push('', 'Result envelope ABI:', 'Respond concisely. End your response with a fenced `wake-result` JSON block, then on its own line after the closing fence repeat the status word for degraded-mode fallback.', `The JSON \`status\` and final line must be exactly one of: ${sentinelListForApproval(input.skipApproval)}.`, sentinelInstructionsForApproval(input.skipApproval), 'The JSON object must contain only the `status` field. Do not add other fields.', 'This envelope is mandatory, not optional formatting: an automated parser reads only your final lines, and a reply that omits it is discarded and treated as BLOCKED regardless of the work you actually completed.');
125
125
  if (input.prTrackingEnabled) {
126
126
  lines.push('', 'Artifact reporting:', 'If you created a pull request during this stage, report it before the result envelope by adding a fenced `wake-artifacts` JSON block:', '```wake-artifacts', '{ "artifacts": [{ "kind": "pr", "url": "<the PR URL>" }] }', '```', 'Only report a PR you actually created in this run. Omit the block entirely if you created no PR.');
127
127
  }
@@ -232,8 +232,9 @@ export async function buildStagePrompt(input) {
232
232
  commentSections,
233
233
  includeRepoDetails: resolvedWorkspaceMode === 'read-only',
234
234
  });
235
+ const envelopeReminder = 'Before you finish: end this reply with the `wake-result` envelope exactly as your instructions describe, or this run is discarded and marked BLOCKED.';
235
236
  return {
236
- prompt: `${renderedTemplate}\n\n${untrustedDataBlock}`,
237
+ prompt: `${renderedTemplate}\n\n${untrustedDataBlock}\n\n${envelopeReminder}`,
237
238
  harnessPrompt: buildHarnessPrompt({
238
239
  skipApproval,
239
240
  prTrackingEnabled: input.config?.sources.github.enabled === true &&
@@ -1,3 +1,5 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFile } from 'node:fs/promises';
1
3
  import { join } from 'node:path';
2
4
  import { createLifecycleService } from './lifecycle-service.js';
3
5
  import { createPolicyEngine } from './policy-engine.js';
@@ -32,6 +34,19 @@ function latestHumanCommentId(candidate) {
32
34
  const human = candidate.comments.filter((c) => !c.isBotAuthored);
33
35
  return human.at(-1)?.id;
34
36
  }
37
+ // Matched as a token at the start of a (trimmed) line, mirroring the
38
+ // /approved and /changes commands in policy-engine.ts.
39
+ const interruptCommandPattern = /^\/interrupt\b/i;
40
+ // Plain comments during a run are additional context for the next turn, not
41
+ // a signal to abandon the current attempt - only an explicit /interrupt
42
+ // should cancel an in-flight run, per the PR #411 follow-up discussion.
43
+ function newHumanCommentsSince(snapshot, refreshed) {
44
+ const knownIds = new Set(snapshot.comments.map((comment) => comment.id));
45
+ return refreshed.comments.filter((comment) => !comment.isBotAuthored && !knownIds.has(comment.id));
46
+ }
47
+ function requestsInterrupt(comments) {
48
+ return comments.some((comment) => comment.body.split(/\r?\n/).some((line) => interruptCommandPattern.test(line.trim())));
49
+ }
35
50
  function latestActionableCommentId(candidate) {
36
51
  const handledCommentId = typeof candidate.context.lastHandledCommentId === 'string'
37
52
  ? candidate.context.lastHandledCommentId
@@ -57,6 +72,22 @@ function projectedSourceRevision(projection) {
57
72
  ? `${projection.issue.repo}#${projection.issue.number}@${projection.issue.updatedAt}`
58
73
  : `${projection.issue.repo}#${projection.issue.number}@${projection.issue.updatedAt};comments@${latestCommentUpdatedAt}`;
59
74
  }
75
+ function stableJson(value) {
76
+ if (value === null || typeof value !== 'object') {
77
+ return JSON.stringify(value);
78
+ }
79
+ if (Array.isArray(value)) {
80
+ return `[${value.map((entry) => stableJson(entry)).join(',')}]`;
81
+ }
82
+ const record = value;
83
+ return `{${Object.keys(record)
84
+ .sort()
85
+ .map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`)
86
+ .join(',')}}`;
87
+ }
88
+ function sha256(value) {
89
+ return `sha256:${createHash('sha256').update(stableJson(value)).digest('hex')}`;
90
+ }
60
91
  function isLateralReadOnlyAction(action, config) {
61
92
  return isCustomCommandAction(action, config);
62
93
  }
@@ -108,7 +139,7 @@ function classifyFailedRun(input) {
108
139
  ? 'unknown'
109
140
  : 'none';
110
141
  const failurePhase = input.failurePhase ??
111
- (input.envelope === 'degraded' && input.sentinel === 'FAILED'
142
+ ((input.envelope === 'degraded' || input.envelope === 'missing') && input.sentinel === 'FAILED'
112
143
  ? 'result-parsing'
113
144
  : failurePhaseForRecord(input.record));
114
145
  let retrySafety;
@@ -587,6 +618,74 @@ export function createTickRunner(deps) {
587
618
  function runnerTimeoutMs() {
588
619
  return maxConfiguredRunnerTimeoutMs(deps.config);
589
620
  }
621
+ async function promptHashForAction(action) {
622
+ const promptsRoot = deps.config.paths.promptsRoot;
623
+ if (promptsRoot === undefined) {
624
+ return sha256({ action, status: 'not-configured' });
625
+ }
626
+ for (const suffix of ['.md', '.start.md', '.resume.md']) {
627
+ const path = join(promptsRoot, `${action}${suffix}`);
628
+ try {
629
+ return `sha256:${createHash('sha256')
630
+ .update(await readFile(path, 'utf8'))
631
+ .digest('hex')}`;
632
+ }
633
+ catch {
634
+ // Try the next supported prompt template name.
635
+ }
636
+ }
637
+ return sha256({ action, status: 'missing' });
638
+ }
639
+ function triggerEventIdForRun(input) {
640
+ if (input.watcherTrigger?.kind === 'event') {
641
+ return input.watcherTrigger.eventId;
642
+ }
643
+ return input.recentEvents.at(-1)?.eventId;
644
+ }
645
+ async function createRunInputSnapshot(input) {
646
+ const validation = input.workspaceValidation !== null &&
647
+ typeof input.workspaceValidation === 'object' &&
648
+ !Array.isArray(input.workspaceValidation)
649
+ ? input.workspaceValidation
650
+ : undefined;
651
+ const repositoryHead = typeof validation?.baseRevision === 'string' ? validation.baseRevision : undefined;
652
+ const workspaceHead = typeof validation?.headRevision === 'string' ? validation.headRevision : undefined;
653
+ const triggerEventId = triggerEventIdForRun({
654
+ ...(input.watcherTrigger === undefined ? {} : { watcherTrigger: input.watcherTrigger }),
655
+ recentEvents: input.recentEvents,
656
+ });
657
+ return deps.stateStore.writeRunInputSnapshot({
658
+ schemaVersion: 1,
659
+ snapshotId: `${input.runId}-input`,
660
+ runId: input.runId,
661
+ createdAt: input.createdAt,
662
+ action: input.action,
663
+ workflowName: input.workflowName,
664
+ claimedStage: input.claimedStage,
665
+ projectionVersion: input.projection.wake.syncedAt,
666
+ sourceUpdatedAt: input.projection.issue.updatedAt,
667
+ sourceRevision: input.sourceRevision,
668
+ ...(triggerEventId === undefined ? {} : { triggerEventId }),
669
+ ...(input.recentEvents.at(-1)?.eventId === undefined
670
+ ? {}
671
+ : { handledThroughEventId: input.recentEvents.at(-1).eventId }),
672
+ workflowHash: await computeWorkflowRevision({
673
+ config: deps.config,
674
+ workflowName: input.workflowName,
675
+ workflow: input.workflow,
676
+ action: input.action,
677
+ }),
678
+ promptHash: await promptHashForAction(input.action),
679
+ ...(repositoryHead === undefined ? {} : { repositoryHead }),
680
+ ...(workspaceHead === undefined ? {} : { workspaceHead }),
681
+ runnerConfigurationHash: sha256({
682
+ routing: input.routing,
683
+ runner: deps.config.runners[input.routing.runnerName],
684
+ }),
685
+ projection: input.projection,
686
+ recentEvents: input.recentEvents,
687
+ });
688
+ }
590
689
  // Counted from durable run records (never an in-memory counter, per the
591
690
  // "tick is a pure function of durable state" invariant), so this holds
592
691
  // across process restarts and is a backstop independent of any specific
@@ -1395,6 +1494,35 @@ export function createTickRunner(deps) {
1395
1494
  timer.unref?.();
1396
1495
  });
1397
1496
  }
1497
+ const recentEvents = await deps.stateStore.listEventEnvelopesForWorkItem(candidate.workItemKey, 6);
1498
+ const activeCandidate = candidate;
1499
+ const runnerProjection = watcherRun
1500
+ ? {
1501
+ ...activeCandidate,
1502
+ wake: {
1503
+ ...activeCandidate.wake,
1504
+ sessionId: undefined,
1505
+ sessionCli: undefined,
1506
+ },
1507
+ }
1508
+ : activeCandidate;
1509
+ const inputSnapshot = await createRunInputSnapshot({
1510
+ runId,
1511
+ createdAt: deps.clock.now().toISOString(),
1512
+ action,
1513
+ workflowName,
1514
+ workflow: deps.config.workflows[workflowName] ?? workflow,
1515
+ claimedStage,
1516
+ projection: runnerProjection,
1517
+ recentEvents,
1518
+ sourceRevision,
1519
+ routing,
1520
+ ...(watcherTriggerForRun === undefined ? {} : { watcherTrigger: watcherTriggerForRun }),
1521
+ });
1522
+ await deps.stateStore.writeRunRecord({
1523
+ ...(await deps.stateStore.readRunRecord(runId)),
1524
+ inputSnapshotId: inputSnapshot.snapshotId,
1525
+ });
1398
1526
  try {
1399
1527
  await transitionRunLifecycle('PREPARING');
1400
1528
  const prepareResult = workspaceMode === 'branch'
@@ -1421,25 +1549,14 @@ export function createTickRunner(deps) {
1421
1549
  ...preparedRecord.metadata,
1422
1550
  ...(workspacePath === undefined ? {} : { workspacePath }),
1423
1551
  workspaceMode,
1552
+ inputSnapshotId: inputSnapshot.snapshotId,
1424
1553
  ...(prepareResult.validation === undefined
1425
1554
  ? {}
1426
1555
  : { workspaceValidation: prepareResult.validation }),
1427
1556
  },
1428
1557
  });
1429
- const recentEvents = await deps.stateStore.listEventEnvelopesForWorkItem(candidate.workItemKey, 6);
1430
1558
  await transitionRunLifecycle('RUNNING');
1431
1559
  startLeaseRenewal();
1432
- const activeCandidate = candidate;
1433
- const runnerProjection = watcherRun
1434
- ? {
1435
- ...activeCandidate,
1436
- wake: {
1437
- ...activeCandidate.wake,
1438
- sessionId: undefined,
1439
- sessionCli: undefined,
1440
- },
1441
- }
1442
- : activeCandidate;
1443
1560
  let executionFinished = false;
1444
1561
  let cancellationReason = null;
1445
1562
  const runnerInput = {
@@ -1516,6 +1633,14 @@ export function createTickRunner(deps) {
1516
1633
  }
1517
1634
  const refreshedProjection = (await deps.stateStore.readIssueState(activeCandidate.workItemKey)) ??
1518
1635
  activeCandidate;
1636
+ const newHumanComments = newHumanCommentsSince(inputSnapshot.projection, refreshedProjection);
1637
+ if (requestsInterrupt(newHumanComments)) {
1638
+ const reason = 'CANCELED_BY_SUPERSEDING_EVENT';
1639
+ cancellationReason = reason;
1640
+ await persistCancellationRequest(reason);
1641
+ await execution.cancel(reason);
1642
+ return reason;
1643
+ }
1519
1644
  const ineligible = activeRefresh.sourceExists === false ||
1520
1645
  !policy.isEligible(refreshedProjection, deps.config);
1521
1646
  if (!ineligible) {
@@ -1549,9 +1674,13 @@ export function createTickRunner(deps) {
1549
1674
  // the protocol; treat it as AWAITING_APPROVAL so the gate is enforced.
1550
1675
  const skipApproval = runnerResult.metadata?.skipApproval;
1551
1676
  const sentinel = rawSentinel === 'DONE' && skipApproval === false ? 'AWAITING_APPROVAL' : rawSentinel;
1552
- const nextStage = isLateralReadOnlyAction(action, deps.config) && sentinel === 'DONE'
1677
+ // A canceled run must not advance the stage regardless of what the
1678
+ // runner echoed back — the snapshot it acted on was superseded.
1679
+ const nextStage = cancellationReason !== null
1553
1680
  ? null
1554
- : lifecycle.nextStageFromSentinel(claimedStage, sentinel, workflow);
1681
+ : isLateralReadOnlyAction(action, deps.config) && sentinel === 'DONE'
1682
+ ? null
1683
+ : lifecycle.nextStageFromSentinel(claimedStage, sentinel, workflow);
1555
1684
  const finishedAt = deps.clock.now().toISOString();
1556
1685
  let workspaceBookkeeping;
1557
1686
  if (workspacePath !== undefined) {
@@ -1674,13 +1803,17 @@ export function createTickRunner(deps) {
1674
1803
  : runnerResult.failureClass === 'infra'
1675
1804
  ? 'PROCESS_FAILED'
1676
1805
  : 'COMPLETED';
1677
- const workflowOutcome = sentinel === 'DONE'
1678
- ? 'DONE'
1679
- : sentinel === 'BLOCKED'
1680
- ? 'BLOCKED'
1681
- : sentinel === 'AWAITING_APPROVAL'
1682
- ? 'AWAITING_APPROVAL'
1683
- : undefined;
1806
+ // Canceled runs don't produce a meaningful workflow outcome; the input
1807
+ // they acted on was superseded so the sentinel is not authoritative.
1808
+ const workflowOutcome = cancellationReason !== null
1809
+ ? undefined
1810
+ : sentinel === 'DONE'
1811
+ ? 'DONE'
1812
+ : sentinel === 'BLOCKED'
1813
+ ? 'BLOCKED'
1814
+ : sentinel === 'AWAITING_APPROVAL'
1815
+ ? 'AWAITING_APPROVAL'
1816
+ : undefined;
1684
1817
  await transitionRunLifecycle('FINALISING');
1685
1818
  const finalisingRecord = (await deps.stateStore.readRunRecord(runId));
1686
1819
  const failureContext = sentinel === 'FAILED'
@@ -1753,11 +1886,13 @@ export function createTickRunner(deps) {
1753
1886
  ...(failureContext === undefined ? {} : failureContext),
1754
1887
  // Only mark the triggering comment handled when the run reached the
1755
1888
  // agent and produced a real outcome. Quota/infra failures are transient
1756
- // blips, not an answer to the human's commentleaving handledCommentId
1757
- // unset lets the next tick retry instead of silently eating the request (S9).
1758
- ...(runnerResult.failureClass === 'quota' || runnerResult.failureClass === 'infra'
1889
+ // blips; canceled runs acted on a superseded snapshotin both cases
1890
+ // leave handledCommentId unset so the next tick can retry (S9).
1891
+ ...(runnerResult.failureClass === 'quota' ||
1892
+ runnerResult.failureClass === 'infra' ||
1893
+ cancellationReason !== null
1759
1894
  ? {}
1760
- : { handledCommentId: latestActionableCommentId(candidate) }),
1895
+ : { handledCommentId: latestActionableCommentId(inputSnapshot.projection) }),
1761
1896
  body: parsedRunnerResult.body,
1762
1897
  envelope: parsedRunnerResult.envelope,
1763
1898
  executionOutcome,
@@ -1,4 +1,4 @@
1
- export const reservedCommandNames = ['approved', 'changes'];
1
+ export const reservedCommandNames = ['approved', 'changes', 'interrupt'];
2
2
  function latestUnhandledHumanComment(issue) {
3
3
  const context = issue.context;
4
4
  const handledCommentId = typeof context.lastHandledCommentId === 'string' ? context.lastHandledCommentId : undefined;
@@ -311,6 +311,27 @@ export const issueStateRecordSchema = z.object({
311
311
  context: z.record(z.string(), z.unknown()).default({}),
312
312
  correlatedResources: z.array(correlatedResourceSchema).default([]),
313
313
  });
314
+ export const runInputSnapshotSchema = z.object({
315
+ schemaVersion: z.literal(1),
316
+ snapshotId: z.string(),
317
+ runId: z.string(),
318
+ createdAt: isoTimestampSchema,
319
+ action: identifierSchema,
320
+ workflowName: identifierSchema,
321
+ claimedStage: identifierSchema,
322
+ projectionVersion: z.string(),
323
+ sourceUpdatedAt: isoTimestampSchema,
324
+ sourceRevision: z.string(),
325
+ triggerEventId: z.string().optional(),
326
+ handledThroughEventId: z.string().optional(),
327
+ workflowHash: z.string(),
328
+ promptHash: z.string(),
329
+ repositoryHead: z.string().optional(),
330
+ workspaceHead: z.string().optional(),
331
+ runnerConfigurationHash: z.string(),
332
+ projection: issueStateRecordSchema,
333
+ recentEvents: z.array(eventEnvelopeSchema),
334
+ });
314
335
  const runTokenUsageSchema = z.object({
315
336
  inputTokens: z.number().nonnegative(),
316
337
  outputTokens: z.number().nonnegative(),
@@ -392,6 +413,7 @@ export const runRecordSchema = z.preprocess((input) => {
392
413
  externalSideEffects: externalSideEffectsSchema.optional(),
393
414
  retrySafety: retrySafetySchema.optional(),
394
415
  summary: z.string().optional(),
416
+ inputSnapshotId: z.string().optional(),
395
417
  routing: runnerRoutingSchema.optional(),
396
418
  lease: runLeaseSchema.optional(),
397
419
  workerPid: z.number().int().positive().optional(),
@@ -979,7 +1001,7 @@ export const wakeConfigSchema = wakeConfigBaseSchema.superRefine((config, ctx) =
979
1001
  ctx.addIssue({
980
1002
  code: z.ZodIssueCode.custom,
981
1003
  path: ['commands', commandName],
982
- message: `Command "/${commandName}" is reserved for Wake approval control.`,
1004
+ message: `Command "/${commandName}" is reserved for Wake's own control flow.`,
983
1005
  });
984
1006
  }
985
1007
  if (command.action === undefined && !promptExists(promptsRoot, commandName)) {
@@ -1008,6 +1030,9 @@ export function parseIssueStateRecord(input) {
1008
1030
  export function parseRunRecord(input) {
1009
1031
  return runRecordSchema.parse(input);
1010
1032
  }
1033
+ export function parseRunInputSnapshot(input) {
1034
+ return runInputSnapshotSchema.parse(input);
1035
+ }
1011
1036
  export function parseEventEnvelope(input) {
1012
1037
  return eventEnvelopeSchema.parse(input);
1013
1038
  }
@@ -1117,7 +1142,11 @@ export function parseRunnerResult(result) {
1117
1142
  return {
1118
1143
  status: body.length === 0 ? 'FAILED' : 'BLOCKED',
1119
1144
  body,
1120
- envelope: 'degraded',
1145
+ // No structured envelope AND no recognizable bare sentinel at all —
1146
+ // distinct from 'degraded' (a deliberate bare-sentinel reply) so
1147
+ // callers can retry a runner that simply forgot the trailer instead
1148
+ // of trusting a fabricated BLOCKED/FAILED default.
1149
+ envelope: 'missing',
1121
1150
  };
1122
1151
  }
1123
1152
  let removed = false;
@@ -32,6 +32,7 @@ export function createWakePaths(wakeRoot) {
32
32
  sourceStateFile: (source, key) => join(dataRoot, 'sources', sanitizePathKey(source), `${sanitizePathKey(key)}.json`),
33
33
  runFile: (runId) => join(dataRoot, 'runs', `${runId}.json`),
34
34
  runDateFile: (date, runId) => join(dataRoot, 'runs', 'by-date', date, `${runId}.json`),
35
+ runInputSnapshotFile: (snapshotId) => join(dataRoot, 'runs', 'input-snapshots', `${sanitizePathKey(snapshotId)}.json`),
35
36
  runDateIndexFile: (date) => join(dataRoot, 'runs', 'by-date', date, 'index.json'),
36
37
  runDateIndexLockFile: (date) => join(dataRoot, 'locks', `run-index-${date}.lock`),
37
38
  eventFile: (date) => join(dataRoot, 'events', `${date}.jsonl`),
@@ -124,4 +124,4 @@ export function resolveWakeVersion(options = {}) {
124
124
  }
125
125
  return '0.1.0-dev';
126
126
  }
127
- export const wakeVersion = "g2fd2fbe";
127
+ export const wakeVersion = "g99d278b";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atolis-hq/wake",
3
- "version": "0.2.59",
3
+ "version": "0.2.61",
4
4
  "description": "Local autonomous agent control plane for software development",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {