@atolis-hq/wake 0.1.5 → 0.1.7

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.
Files changed (35) hide show
  1. package/README.md +1 -1
  2. package/dist/src/adapters/claude/claude-runner.js +9 -30
  3. package/dist/src/adapters/codex/codex-runner.js +5 -10
  4. package/dist/src/adapters/fake/fake-github-pull-request-activity-source.js +5 -1
  5. package/dist/src/adapters/fake/fake-runner.js +2 -2
  6. package/dist/src/adapters/fake/fake-ticketing-system.js +3 -8
  7. package/dist/src/adapters/fake/fake-workspace-manager.js +1 -1
  8. package/dist/src/adapters/fs/state-store.js +8 -10
  9. package/dist/src/adapters/github/github-auth.js +1 -1
  10. package/dist/src/adapters/github/github-issues-work-source.js +8 -14
  11. package/dist/src/adapters/github/github-pull-request-activity-source.js +44 -9
  12. package/dist/src/adapters/http/ui-data.js +23 -12
  13. package/dist/src/adapters/http/ui-server.js +14 -4
  14. package/dist/src/adapters/runner/runner-registry.js +2 -4
  15. package/dist/src/adapters/runner/stage-prompt.js +15 -18
  16. package/dist/src/cli/sandbox-command.js +8 -6
  17. package/dist/src/cli/startup-preflight.js +1 -1
  18. package/dist/src/core/control-plane.js +6 -2
  19. package/dist/src/core/policy-engine.js +20 -21
  20. package/dist/src/core/projection-updater.js +11 -14
  21. package/dist/src/core/quota-backoff.js +1 -1
  22. package/dist/src/core/sink-router.js +5 -7
  23. package/dist/src/core/tick-runner.js +88 -73
  24. package/dist/src/domain/runner-routing.js +1 -1
  25. package/dist/src/domain/schema.js +213 -58
  26. package/dist/src/domain/stages.js +1 -4
  27. package/dist/src/domain/workflows.js +1 -3
  28. package/dist/src/lib/lock.js +1 -1
  29. package/dist/src/main.js +5 -8
  30. package/dist/src/version.js +1 -1
  31. package/package.json +10 -2
  32. package/prompts/codereview.md +40 -0
  33. package/prompts/implement.md +4 -0
  34. package/prompts/refine.md +2 -0
  35. package/prompts/revise.md +4 -3
@@ -4,13 +4,11 @@ import { createRunnerCliAdapter } from '../adapters/runner/runner-cli-adapter.js
4
4
  import { runSandboxResumeCommand } from './sandbox-resume.js';
5
5
  import { runSelfUpdateCommand, runSelfUpdateLoop } from './self-update-command.js';
6
6
  import { runStopCommand } from './stop-command.js';
7
- import { buildSandboxLoggedCommand, } from './sandbox-logging.js';
7
+ import { buildSandboxLoggedCommand } from './sandbox-logging.js';
8
8
  async function ensureContainerHomeMountParents(input) {
9
9
  for (const mount of input.extraMounts) {
10
10
  const relativeTarget = posix.relative(input.containerHomeMountPath, mount.target);
11
- if (relativeTarget.length === 0 ||
12
- relativeTarget === '.' ||
13
- relativeTarget.startsWith('..')) {
11
+ if (relativeTarget.length === 0 || relativeTarget === '.' || relativeTarget.startsWith('..')) {
14
12
  continue;
15
13
  }
16
14
  const parentRelativeTarget = posix.dirname(relativeTarget);
@@ -118,7 +116,9 @@ export async function runSandboxCommand(input) {
118
116
  throw new Error('Sandbox self-update requires git/issueReporter/ledger dependencies');
119
117
  }
120
118
  const selfUpdateArgs = input.args.slice(1);
121
- const runSelfUpdate = selfUpdateArgs.includes('--loop') ? runSelfUpdateLoop : runSelfUpdateCommand;
119
+ const runSelfUpdate = selfUpdateArgs.includes('--loop')
120
+ ? runSelfUpdateLoop
121
+ : runSelfUpdateCommand;
122
122
  await runSelfUpdate({
123
123
  args: selfUpdateArgs,
124
124
  repoRoot,
@@ -146,7 +146,9 @@ export async function runSandboxCommand(input) {
146
146
  return;
147
147
  }
148
148
  if (subcommand === 'setup') {
149
- await input.docker.exec(input.config.sandbox.containerName, ['bash', '/wake/docker/setup.sh'], { interactive: true });
149
+ await input.docker.exec(input.config.sandbox.containerName, ['bash', '/wake/docker/setup.sh'], {
150
+ interactive: true,
151
+ });
150
152
  return;
151
153
  }
152
154
  if (subcommand === 'exec') {
@@ -52,7 +52,7 @@ async function defaultCheckRunnerCommand(runnerName, entry) {
52
52
  }
53
53
  catch (error) {
54
54
  const message = error instanceof Error ? error.message : String(error);
55
- throw new Error(`runner "${runnerName}" (${entry.kind}) command "${entry.command}" is not invocable: ${message}`);
55
+ throw new Error(`runner "${runnerName}" (${entry.kind}) command "${entry.command}" is not invocable: ${message}`, { cause: error });
56
56
  }
57
57
  }
58
58
  async function assertPromptsRootAccessible(promptsRoot) {
@@ -91,13 +91,17 @@ export function createControlPlane(deps) {
91
91
  run: deps.tickRunner.runIntakeTick,
92
92
  label: 'intake',
93
93
  getIdleTicks: () => consecutiveIntakeIdleTicks,
94
- setIdleTicks: (value) => { consecutiveIntakeIdleTicks = value; },
94
+ setIdleTicks: (value) => {
95
+ consecutiveIntakeIdleTicks = value;
96
+ },
95
97
  }),
96
98
  startLoop({
97
99
  run: deps.tickRunner.runRunnerTick,
98
100
  label: 'runner',
99
101
  getIdleTicks: () => consecutiveRunnerIdleTicks,
100
- setIdleTicks: (value) => { consecutiveRunnerIdleTicks = value; },
102
+ setIdleTicks: (value) => {
103
+ consecutiveRunnerIdleTicks = value;
104
+ },
101
105
  }),
102
106
  ]);
103
107
  return;
@@ -1,5 +1,5 @@
1
- import { awaitingApprovalRunnerSentinel, failedRunnerSentinel, } from '../domain/stages.js';
2
- import { builtInDefaultWorkflowDefinition, chooseAction as chooseWorkflowAction } from '../domain/workflows.js';
1
+ import { awaitingApprovalRunnerSentinel, failedRunnerSentinel } from '../domain/stages.js';
2
+ import { builtInDefaultWorkflowDefinition, chooseAction as chooseWorkflowAction, } from '../domain/workflows.js';
3
3
  function isAwaitingApproval(issue) {
4
4
  const context = issue.context;
5
5
  return context.lastRunSentinel === awaitingApprovalRunnerSentinel;
@@ -10,14 +10,14 @@ function isAwaitingApproval(issue) {
10
10
  const approvedCommandPattern = /^\/approved\b/i;
11
11
  const changesCommandPattern = /^\/changes\b/i;
12
12
  const questionCommandPattern = /^\/question\b/i;
13
+ const codeReviewCommandPattern = /^\/codereview\b/i;
13
14
  // The action Wake runs when a correlated PR gets new reviewer feedback while
14
15
  // the work item is awaiting approval. Not configurable per workflow: it's a
15
16
  // lateral response to a PR surface, not a workflow stage.
16
17
  const reviewFeedbackAction = 'revise';
18
+ const codeReviewAction = 'codereview';
17
19
  function matchesCommand(body, pattern) {
18
- return body
19
- .split(/\r?\n/)
20
- .some((line) => pattern.test(line.trim()));
20
+ return body.split(/\r?\n/).some((line) => pattern.test(line.trim()));
21
21
  }
22
22
  function labelsAndAssigneesQualify(input) {
23
23
  if (input.requiredLabels.length === 0 && input.requiredAssignees.length === 0) {
@@ -31,16 +31,15 @@ function labelsAndAssigneesQualify(input) {
31
31
  if (input.ignoredLabels.some((label) => labels.has(label))) {
32
32
  return false;
33
33
  }
34
- if (input.requiredAssignees.length > 0 && !input.requiredAssignees.some((login) => assignees.has(login))) {
34
+ if (input.requiredAssignees.length > 0 &&
35
+ !input.requiredAssignees.some((login) => assignees.has(login))) {
35
36
  return false;
36
37
  }
37
38
  return true;
38
39
  }
39
40
  function latestUnhandledHumanComment(issue) {
40
41
  const context = issue.context;
41
- const handledCommentId = typeof context.lastHandledCommentId === 'string'
42
- ? context.lastHandledCommentId
43
- : undefined;
42
+ const handledCommentId = typeof context.lastHandledCommentId === 'string' ? context.lastHandledCommentId : undefined;
44
43
  // Only consider human comments that appear after the last bot comment.
45
44
  // A human /approved posted before Wake's approval-request comment must not
46
45
  // be re-consumed as approval for a later awaiting-approval cycle.
@@ -78,18 +77,10 @@ export function createPolicyEngine() {
78
77
  },
79
78
  needsWakeAction(issue, workflow = builtInDefaultWorkflowDefinition) {
80
79
  const context = issue.context;
81
- const handledCommentId = typeof context.lastHandledCommentId === 'string'
82
- ? context.lastHandledCommentId
83
- : undefined;
84
- const lastCompletedAction = typeof context.lastCompletedAction === 'string'
85
- ? context.lastCompletedAction
86
- : undefined;
87
- const lastRunSentinel = typeof context.lastRunSentinel === 'string'
88
- ? context.lastRunSentinel
89
- : undefined;
90
- const lastFailureClass = typeof context.lastFailureClass === 'string'
91
- ? context.lastFailureClass
92
- : undefined;
80
+ const handledCommentId = typeof context.lastHandledCommentId === 'string' ? context.lastHandledCommentId : undefined;
81
+ const lastCompletedAction = typeof context.lastCompletedAction === 'string' ? context.lastCompletedAction : undefined;
82
+ const lastRunSentinel = typeof context.lastRunSentinel === 'string' ? context.lastRunSentinel : undefined;
83
+ const lastFailureClass = typeof context.lastFailureClass === 'string' ? context.lastFailureClass : undefined;
93
84
  if (issue.wake.lastRunId === undefined) {
94
85
  return true;
95
86
  }
@@ -182,6 +173,14 @@ export function createPolicyEngine() {
182
173
  }
183
174
  return reviewFeedbackAction;
184
175
  },
176
+ resolveCodeReviewRequest(issue) {
177
+ const latestHumanComment = latestUnhandledHumanComment(issue);
178
+ if (latestHumanComment === undefined ||
179
+ !matchesCommand(latestHumanComment.body, codeReviewCommandPattern)) {
180
+ return null;
181
+ }
182
+ return codeReviewAction;
183
+ },
185
184
  qualifiesForMint(unresolved, config) {
186
185
  const resourceUri = unresolved.sourceRefs.resourceUri;
187
186
  if (resourceUri === undefined) {
@@ -15,14 +15,12 @@ function configuredStagesForLabels(config) {
15
15
  return workflow === undefined ? undefined : workflowStageVocabulary(workflow);
16
16
  }
17
17
  function createProjectionFromIssueEvent(event, config) {
18
- const issue = event.sourceEventType === 'ticket.upsert'
19
- ? event.payload.ticket
20
- : event.payload.issue;
18
+ const issue = event.sourceEventType === 'ticket.upsert' ? event.payload.ticket : event.payload.issue;
21
19
  if (issue === undefined || typeof issue !== 'object' || issue === null) {
22
20
  return null;
23
21
  }
24
22
  const labels = Array.isArray(issue.labels)
25
- ? (issue.labels)
23
+ ? issue.labels
26
24
  : [];
27
25
  return parseIssueStateRecord({
28
26
  schemaVersion: 1,
@@ -50,8 +48,7 @@ async function applyEvent(current, event, ctx, config) {
50
48
  if (event.workItemKey === UNRESOLVED_WORK_ITEM_KEY) {
51
49
  return current;
52
50
  }
53
- if (event.sourceEventType === 'fake.issue.upsert' ||
54
- event.sourceEventType === 'ticket.upsert') {
51
+ if (event.sourceEventType === 'fake.issue.upsert' || event.sourceEventType === 'ticket.upsert') {
55
52
  const next = createProjectionFromIssueEvent(event, config);
56
53
  if (next === null) {
57
54
  return current;
@@ -150,10 +147,10 @@ async function applyEvent(current, event, ctx, config) {
150
147
  // Clear the session when the stage moves forward (new action needed) or the
151
148
  // run failed outright. Keep it for BLOCKED so the same action can resume
152
149
  // the in-progress session after a human replies.
153
- const isForwardProgression = payload.nextStage !== undefined &&
154
- payload.nextStage !== current.wake.stage;
150
+ const isForwardProgression = payload.nextStage !== undefined && payload.nextStage !== current.wake.stage;
155
151
  const stageChanged = payload.nextStage !== undefined && payload.nextStage !== current.wake.stage;
156
152
  const isFailed = payload.sentinel === 'FAILED';
153
+ const isCompletedCodeReview = payload.action === 'codereview' && payload.sentinel === doneRunnerSentinel;
157
154
  const shouldClearSession = isForwardProgression || isFailed;
158
155
  return parseIssueStateRecord({
159
156
  ...current,
@@ -163,13 +160,15 @@ async function applyEvent(current, event, ctx, config) {
163
160
  ...(payload.handledCommentId === undefined
164
161
  ? {}
165
162
  : { lastHandledCommentId: payload.handledCommentId }),
166
- ...(payload.sentinel === undefined
163
+ ...(payload.sentinel === undefined || isCompletedCodeReview
167
164
  ? {}
168
165
  : { lastRunSentinel: payload.sentinel }),
169
- ...(payload.action === undefined
166
+ ...(payload.action === undefined || isCompletedCodeReview
170
167
  ? {}
171
168
  : { lastRunAction: payload.action }),
172
- ...(payload.sentinel === doneRunnerSentinel && payload.action !== undefined
169
+ ...(payload.sentinel === doneRunnerSentinel &&
170
+ payload.action !== undefined &&
171
+ !isCompletedCodeReview
173
172
  ? { lastCompletedAction: payload.action }
174
173
  : {}),
175
174
  // Remembered so the approval path knows which action to resume or
@@ -182,9 +181,7 @@ async function applyEvent(current, event, ctx, config) {
182
181
  ...current.wake,
183
182
  stage: payload.nextStage ?? current.wake.stage,
184
183
  lastRunId: payload.runId ?? current.wake.lastRunId,
185
- sessionId: shouldClearSession
186
- ? undefined
187
- : (payload.sessionId ?? current.wake.sessionId),
184
+ sessionId: shouldClearSession ? undefined : (payload.sessionId ?? current.wake.sessionId),
188
185
  sessionCli: shouldClearSession
189
186
  ? undefined
190
187
  : (payload.sessionCli ?? current.wake.sessionCli),
@@ -61,7 +61,7 @@ export function resolveQuotaPauseUntil(input) {
61
61
  };
62
62
  }
63
63
  const boundedFailureCount = Math.max(1, Math.min(input.failureCount, 6));
64
- const delayMs = Math.min(15 * 60_000 * (2 ** (boundedFailureCount - 1)), maxEstimatedPauseMs);
64
+ const delayMs = Math.min(15 * 60_000 * 2 ** (boundedFailureCount - 1), maxEstimatedPauseMs);
65
65
  return {
66
66
  pausedUntil: new Date(input.now.getTime() + delayMs).toISOString(),
67
67
  source: 'estimated',
@@ -46,18 +46,16 @@ export function createOutboundSinkRouter(input) {
46
46
  const origin = typeof event.payload.origin === 'string' ? event.payload.origin : undefined;
47
47
  const sourceOrigin = event.sourceRefs.sink ?? origin;
48
48
  if (event.sourceEventType === 'wake.labels.requested') {
49
- const projectionOrigin = typeof event.payload.origin === 'string'
50
- ? event.payload.origin
51
- : undefined;
49
+ const projectionOrigin = typeof event.payload.origin === 'string' ? event.payload.origin : undefined;
52
50
  if (projectionOrigin !== undefined) {
53
51
  targetSinks.add(projectionOrigin);
54
52
  }
55
53
  }
56
- const kind = intentKind(event);
57
54
  const resourceUri = event.sourceRefs.resourceUri;
58
- if (event.sourceEventType === 'wake.publish.intent.requested' &&
59
- sourceOrigin !== undefined) {
60
- const resourceSink = resourceUri === undefined ? sourceOrigin : sinkNameForResourceUri(resourceUri, sourceOrigin);
55
+ if (event.sourceEventType === 'wake.publish.intent.requested' && sourceOrigin !== undefined) {
56
+ const resourceSink = resourceUri === undefined
57
+ ? sourceOrigin
58
+ : sinkNameForResourceUri(resourceUri, sourceOrigin);
61
59
  // A resource-derived sink name (e.g. a PR surface) may not be
62
60
  // registered — the source that owns it can be disabled independently
63
61
  // of the origin sink. Falling back to sourceOrigin here, rather than
@@ -36,6 +36,9 @@ function latestHumanCommentId(candidate) {
36
36
  function isReviewThreadResourceUri(resourceUri) {
37
37
  return resourceUri.split(':')[1] === 'pr-review-thread';
38
38
  }
39
+ function isLateralReadOnlyAction(action) {
40
+ return action === 'codereview';
41
+ }
39
42
  function isFreshTriggeringComment(candidate) {
40
43
  const context = candidate.context;
41
44
  const handledCommentId = typeof context.lastHandledCommentId === 'string' ? context.lastHandledCommentId : undefined;
@@ -169,9 +172,7 @@ export function createTickRunner(deps) {
169
172
  ...(input.runnerResult.tokenUsage?.costUsd === undefined
170
173
  ? {}
171
174
  : { cost: formatCostUsd(input.runnerResult.tokenUsage.costUsd) }),
172
- ...(input.workspacePath === undefined
173
- ? {}
174
- : { workspacePath: input.workspacePath }),
175
+ ...(input.workspacePath === undefined ? {} : { workspacePath: input.workspacePath }),
175
176
  },
176
177
  derivedHints: {
177
178
  stage: input.sentinel === 'DONE' ? 'done' : input.projection.wake.stage,
@@ -211,10 +212,12 @@ export function createTickRunner(deps) {
211
212
  return false;
212
213
  }
213
214
  if (isAwaitingApproval(projection)) {
214
- return (policy.resolveApprovalTransition(projection) !== null ||
215
+ return (policy.resolveCodeReviewRequest(projection) !== null ||
216
+ policy.resolveApprovalTransition(projection) !== null ||
215
217
  policy.resolvePendingReviewFeedback(projection) !== null);
216
218
  }
217
- const nextAction = policy.chooseAction(projection, workflow) ??
219
+ const nextAction = policy.resolveCodeReviewRequest(projection) ??
220
+ policy.chooseAction(projection, workflow) ??
218
221
  policy.chooseRetryActionAfterHumanReply(projection);
219
222
  return nextAction !== null && policy.needsWakeAction(projection, workflow);
220
223
  }
@@ -468,7 +471,8 @@ export function createTickRunner(deps) {
468
471
  const owner = await deps.resourceIndex.resolve(resourceUri);
469
472
  if (persisted.workItemKey !== UNRESOLVED_WORK_ITEM_KEY &&
470
473
  owner === undefined &&
471
- (await deps.stateStore.readEventEnvelope(`${persisted.workItemKey}-origin-correlation`)) === null) {
474
+ (await deps.stateStore.readEventEnvelope(`${persisted.workItemKey}-origin-correlation`)) ===
475
+ null) {
472
476
  return [
473
477
  { envelope: persisted, persisted: true },
474
478
  ...buildOriginCorrelationEvents(persisted.workItemKey, unkeyed, resourceUri).map((envelope) => ({ envelope, persisted: false })),
@@ -653,8 +657,7 @@ export function createTickRunner(deps) {
653
657
  async function reconcileStaleRunningRecords(now) {
654
658
  const finishedAt = now.toISOString();
655
659
  const runRecords = await deps.stateStore.listRunRecords();
656
- const staleRecords = runRecords
657
- .filter((record) => isStaleRunningRecord(record, now));
660
+ const staleRecords = runRecords.filter((record) => isStaleRunningRecord(record, now));
658
661
  for (const record of staleRecords) {
659
662
  // Run records carry the work item they belong to, so this is a direct
660
663
  // O(1) read — no scan, no index, no source ambiguity. The record's
@@ -949,7 +952,8 @@ export function createTickRunner(deps) {
949
952
  if (isAwaitingApproval(issue)) {
950
953
  return policy.needsWakeAction(issue, workflow);
951
954
  }
952
- const nextAction = policy.chooseAction(issue, workflow) ??
955
+ const nextAction = policy.resolveCodeReviewRequest(issue) ??
956
+ policy.chooseAction(issue, workflow) ??
953
957
  policy.chooseRetryActionAfterHumanReply(issue);
954
958
  return nextAction !== null && policy.needsWakeAction(issue, workflow);
955
959
  });
@@ -965,69 +969,77 @@ export function createTickRunner(deps) {
965
969
  let claimedStage = candidate.wake.stage;
966
970
  let workspaceMode = 'none';
967
971
  if (isAwaitingApproval(candidate)) {
968
- const approvalResolution = policy.resolveApprovalTransition(candidate);
969
- if (approvalResolution === null) {
970
- const reviewAction = policy.resolvePendingReviewFeedback(candidate);
971
- if (reviewAction === null) {
972
- return { status: 'idle' };
973
- }
974
- action = reviewAction;
975
- const workflowAction = chooseWorkflowAction(candidate, workflow);
976
- claimedStage = workflowAction?.stage ?? candidate.wake.stage;
977
- workspaceMode = workflowAction?.workspace ?? 'none';
972
+ const codeReviewRequest = policy.resolveCodeReviewRequest(candidate);
973
+ if (codeReviewRequest !== null) {
974
+ action = codeReviewRequest;
975
+ claimedStage = candidate.wake.stage;
976
+ workspaceMode = 'read-only';
978
977
  }
979
- else if (approvalResolution.approved) {
980
- const approvalId = `approval-${candidate.issue.number}-${deps.clock.now().getTime()}`;
981
- const approvedAt = deps.clock.now().toISOString();
982
- const nextStage = lifecycle.nextStageFromSentinel(candidate.wake.stage, 'DONE', workflow);
983
- if (nextStage === null) {
984
- return { status: 'idle' };
978
+ else {
979
+ const approvalResolution = policy.resolveApprovalTransition(candidate);
980
+ if (approvalResolution === null) {
981
+ const reviewAction = policy.resolvePendingReviewFeedback(candidate);
982
+ if (reviewAction === null) {
983
+ return { status: 'idle' };
984
+ }
985
+ action = reviewAction;
986
+ const workflowAction = chooseWorkflowAction(candidate, workflow);
987
+ claimedStage = workflowAction?.stage ?? candidate.wake.stage;
988
+ workspaceMode = workflowAction?.workspace ?? 'none';
985
989
  }
986
- const approvalCompletedEvent = createEventEnvelope({
987
- eventId: `${approvalId}-completed`,
988
- workItemKey: candidate.workItemKey,
989
- streamScope: 'work-item',
990
- direction: 'internal',
991
- sourceSystem: 'wake',
992
- sourceEventType: 'wake.run.completed',
993
- sourceRefs: {
994
- repo: candidate.issue.repo,
995
- issueNumber: candidate.issue.number,
990
+ else if (approvalResolution.approved) {
991
+ const approvalId = `approval-${candidate.issue.number}-${deps.clock.now().getTime()}`;
992
+ const approvedAt = deps.clock.now().toISOString();
993
+ const nextStage = lifecycle.nextStageFromSentinel(candidate.wake.stage, 'DONE', workflow);
994
+ if (nextStage === null) {
995
+ return { status: 'idle' };
996
+ }
997
+ const approvalCompletedEvent = createEventEnvelope({
998
+ eventId: `${approvalId}-completed`,
999
+ workItemKey: candidate.workItemKey,
1000
+ streamScope: 'work-item',
1001
+ direction: 'internal',
1002
+ sourceSystem: 'wake',
1003
+ sourceEventType: 'wake.run.completed',
1004
+ sourceRefs: {
1005
+ repo: candidate.issue.repo,
1006
+ issueNumber: candidate.issue.number,
1007
+ runId: approvalId,
1008
+ },
1009
+ occurredAt: approvedAt,
1010
+ ingestedAt: approvedAt,
1011
+ trigger: 'immediate',
1012
+ payload: {
1013
+ action: approvalResolution.pendingAction,
1014
+ sentinel: 'DONE',
1015
+ nextStage,
1016
+ runId: approvalId,
1017
+ reason: 'human:approved',
1018
+ handledCommentId: latestHumanCommentId(candidate),
1019
+ },
1020
+ });
1021
+ await deps.stateStore.appendEventEnvelope(approvalCompletedEvent);
1022
+ await projectionUpdater.rebuildFromEvents([approvalCompletedEvent]);
1023
+ await deliverOutboundEvent(createLabelsEvent({
1024
+ projection: candidate,
1025
+ runId: approvalId,
1026
+ statusLabel: statusLabelForStage(nextStage),
1027
+ stageLabel: stageLabelForStage(nextStage),
1028
+ occurredAt: approvedAt,
1029
+ }));
1030
+ return {
1031
+ status: 'processed',
996
1032
  runId: approvalId,
997
- },
998
- occurredAt: approvedAt,
999
- ingestedAt: approvedAt,
1000
- trigger: 'immediate',
1001
- payload: {
1002
- action: approvalResolution.pendingAction,
1003
1033
  sentinel: 'DONE',
1004
1034
  nextStage,
1005
- runId: approvalId,
1006
- reason: 'human:approved',
1007
- handledCommentId: latestHumanCommentId(candidate),
1008
- },
1009
- });
1010
- await deps.stateStore.appendEventEnvelope(approvalCompletedEvent);
1011
- await projectionUpdater.rebuildFromEvents([approvalCompletedEvent]);
1012
- await deliverOutboundEvent(createLabelsEvent({
1013
- projection: candidate,
1014
- runId: approvalId,
1015
- statusLabel: statusLabelForStage(nextStage),
1016
- stageLabel: stageLabelForStage(nextStage),
1017
- occurredAt: approvedAt,
1018
- }));
1019
- return {
1020
- status: 'processed',
1021
- runId: approvalId,
1022
- sentinel: 'DONE',
1023
- nextStage,
1024
- };
1025
- }
1026
- else {
1027
- action = approvalResolution.pendingAction;
1028
- const workflowAction = chooseWorkflowAction(candidate, workflow);
1029
- claimedStage = workflowAction?.stage ?? candidate.wake.stage;
1030
- workspaceMode = workflowAction?.workspace ?? 'none';
1035
+ };
1036
+ }
1037
+ else {
1038
+ action = approvalResolution.pendingAction;
1039
+ const workflowAction = chooseWorkflowAction(candidate, workflow);
1040
+ claimedStage = workflowAction?.stage ?? candidate.wake.stage;
1041
+ workspaceMode = workflowAction?.workspace ?? 'none';
1042
+ }
1031
1043
  }
1032
1044
  }
1033
1045
  else {
@@ -1042,7 +1054,8 @@ export function createTickRunner(deps) {
1042
1054
  // scratch (#258 follow-up incident: a FAILED `revise` run fell
1043
1055
  // back to a full fresh `implement` run and lost the PR-feedback
1044
1056
  // context).
1045
- const nextAction = policy.chooseRetryActionAfterHumanReply(candidate) ??
1057
+ const nextAction = policy.resolveCodeReviewRequest(candidate) ??
1058
+ policy.chooseRetryActionAfterHumanReply(candidate) ??
1046
1059
  workflowAction?.action ??
1047
1060
  null;
1048
1061
  if (nextAction === null) {
@@ -1050,7 +1063,9 @@ export function createTickRunner(deps) {
1050
1063
  }
1051
1064
  action = nextAction;
1052
1065
  claimedStage = workflowAction?.stage ?? candidate.wake.stage;
1053
- workspaceMode = workflowAction?.workspace ?? 'none';
1066
+ workspaceMode = isLateralReadOnlyAction(action)
1067
+ ? 'read-only'
1068
+ : (workflowAction?.workspace ?? 'none');
1054
1069
  }
1055
1070
  // Resolve routing (with sideways fallback across quota-paused runners,
1056
1071
  // #67) before claiming a run, so a fully-paused tier costs nothing more
@@ -1146,10 +1161,10 @@ export function createTickRunner(deps) {
1146
1161
  // An agent that writes DONE but was told not to skip approval has violated
1147
1162
  // the protocol; treat it as AWAITING_APPROVAL so the gate is enforced.
1148
1163
  const skipApproval = runnerResult.metadata?.skipApproval;
1149
- const sentinel = rawSentinel === 'DONE' && skipApproval === false
1150
- ? 'AWAITING_APPROVAL'
1151
- : rawSentinel;
1152
- const nextStage = lifecycle.nextStageFromSentinel(claimedStage, sentinel, workflow);
1164
+ const sentinel = rawSentinel === 'DONE' && skipApproval === false ? 'AWAITING_APPROVAL' : rawSentinel;
1165
+ const nextStage = isLateralReadOnlyAction(action) && sentinel === 'DONE'
1166
+ ? null
1167
+ : lifecycle.nextStageFromSentinel(claimedStage, sentinel, workflow);
1153
1168
  const finishedAt = deps.clock.now().toISOString();
1154
1169
  if (workspaceMode === 'branch') {
1155
1170
  await registerReportedArtifacts({
@@ -15,7 +15,7 @@ export function maxConfiguredRunnerTimeoutMs(config) {
15
15
  }
16
16
  const registryTimeouts = [...activeRunnerNames]
17
17
  .map((name) => config.runners[name])
18
- .map((entry) => entry === undefined || entry.kind === 'fake' ? undefined : entry.timeoutMs)
18
+ .map((entry) => (entry === undefined || entry.kind === 'fake' ? undefined : entry.timeoutMs))
19
19
  .filter((timeout) => timeout !== undefined);
20
20
  return registryTimeouts.length > 0 ? Math.max(...registryTimeouts) : Infinity;
21
21
  }