@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.
- package/dist/src/adapters/claude/claude-runner.js +103 -4
- package/dist/src/adapters/fs/state-store.js +17 -1
- package/dist/src/adapters/runner/stage-prompt.js +4 -3
- package/dist/src/core/tick-runner.js +161 -26
- package/dist/src/domain/custom-commands.js +1 -1
- package/dist/src/domain/schema.js +31 -2
- package/dist/src/lib/paths.js +1 -0
- package/dist/src/version.js +1 -1
- package/package.json +1 -1
|
@@ -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:
|
|
465
|
+
result: effectiveResultText,
|
|
368
466
|
model,
|
|
369
467
|
cli: CLAUDE_CLI_NAME,
|
|
370
|
-
...(parseRunnerResult(
|
|
468
|
+
...(parseRunnerResult(effectiveResultText).status === 'FAILED'
|
|
371
469
|
? { failureClass: 'task' }
|
|
372
470
|
: {}),
|
|
373
471
|
...(parsed.session_id === undefined ? {} : { session_id: parsed.session_id }),
|
|
374
|
-
...(
|
|
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
|
-
|
|
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
|
-
:
|
|
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
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
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
|
|
1757
|
-
// unset
|
|
1758
|
-
...(runnerResult.failureClass === 'quota' ||
|
|
1889
|
+
// blips; canceled runs acted on a superseded snapshot — in 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(
|
|
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
|
|
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
|
|
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;
|
package/dist/src/lib/paths.js
CHANGED
|
@@ -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`),
|
package/dist/src/version.js
CHANGED