@atolis-hq/wake 0.2.60 → 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.
|
@@ -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)) {
|
|
@@ -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
|
}
|
|
@@ -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
|
}
|
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