@atolis-hq/wake 0.2.60 → 0.2.62

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)) {
@@ -312,7 +312,10 @@ async function renderBoard(context) {
312
312
  ...(item.labels && item.labels.length > 0
313
313
  ? [el('div', { class: 'meta' }, item.labels.map((label) => el('span', { class: 'chip chip-label', text: label })))]
314
314
  : []),
315
- el('div', { class: 'meta', text: item.lastRunSentinel ? 'last: ' + item.lastRunAction + ' → ' + item.lastRunSentinel : item.conditionReason }),
315
+ ...(item.activeMainRun
316
+ ? []
317
+ : [el('div', { class: 'meta', text: item.lastRunSentinel ? 'last: ' + item.lastRunAction + ' → ' + item.lastRunSentinel : item.conditionReason })]),
318
+ ...(item.activeMainRun ? [renderChildRun(item.activeMainRun)] : []),
316
319
  ...((item.activeChildRuns || []).map(renderChildRun)),
317
320
  ]));
318
321
  return el('div', { class: 'col' + (items.length === 0 ? ' col-empty' : '') }, [
@@ -107,6 +107,18 @@ export async function buildBoard(input) {
107
107
  const lastRun = item.wake.lastRunId === undefined ? null : (runsById.get(item.wake.lastRunId) ?? null);
108
108
  const { condition, reason } = deriveCondition(item, lastRun, input.config);
109
109
  const activeChildRuns = activeChildRunsForItem(item, runs, input.now);
110
+ const activeMainRun = lastRun?.status === 'running'
111
+ ? {
112
+ runId: lastRun.runId,
113
+ action: lastRun.action,
114
+ startedAt: lastRun.startedAt,
115
+ ageMs: input.now.getTime() - Date.parse(lastRun.startedAt),
116
+ ...(lastRun.routing?.runnerName === undefined
117
+ ? {}
118
+ : { runnerName: lastRun.routing.runnerName }),
119
+ ...(lastRun.routing?.tier === undefined ? {} : { tier: lastRun.routing.tier }),
120
+ }
121
+ : undefined;
110
122
  return {
111
123
  repo: item.issue.repo,
112
124
  number: item.issue.number,
@@ -120,6 +132,7 @@ export async function buildBoard(input) {
120
132
  lastRunAction: lastRun?.action,
121
133
  lastRunSentinel: lastRun?.sentinel,
122
134
  lastRunStatus: lastRun?.status,
135
+ ...(activeMainRun === undefined ? {} : { activeMainRun }),
123
136
  ...(activeChildRuns.length === 0 ? {} : { activeChildRuns }),
124
137
  sessionId: item.wake.sessionId,
125
138
  workspacePath: item.wake.workspacePath,
@@ -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
- 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
  }
@@ -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 = "gf59c4fc";
127
+ export const wakeVersion = "gf68a086";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atolis-hq/wake",
3
- "version": "0.2.60",
3
+ "version": "0.2.62",
4
4
  "description": "Local autonomous agent control plane for software development",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {