@astrosheep/keiyaku 2.9.6 → 2.9.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 (54) hide show
  1. package/build/.tsbuildinfo +1 -1
  2. package/build/agents/harness/outcome.js +10 -0
  3. package/build/cli/commands/contract/bind/handler.js +10 -4
  4. package/build/cli/commands/contract/bind/meta.js +6 -6
  5. package/build/cli/commands/contract/petition/handler.js +16 -3
  6. package/build/cli/commands/contract/petition/meta.js +4 -4
  7. package/build/cli/commands/metadata.js +3 -2
  8. package/build/cli/commands/projection/tell/handler.js +10 -2
  9. package/build/cli/commands/projection/tell/meta.js +3 -3
  10. package/build/cli/commands/task/catalog.js +2 -0
  11. package/build/cli/commands/task/log/handler.js +12 -0
  12. package/build/cli/commands/task/log/meta.js +8 -0
  13. package/build/cli/flags.js +8 -0
  14. package/build/cli/index.js +10 -6
  15. package/build/cli/parse-flags.js +6 -0
  16. package/build/cli/parse-metadata.js +1 -1
  17. package/build/cli/render/line-width.js +33 -0
  18. package/build/cli/render/path-prefix-compaction.js +88 -0
  19. package/build/cli/render/petition.js +4 -0
  20. package/build/cli/render/projection-activity.js +93 -20
  21. package/build/cli/render/shared.js +34 -12
  22. package/build/cli/render/success-response.js +2 -0
  23. package/build/cli/render/tool-presentation.js +3 -3
  24. package/build/cli/render/wait.js +68 -48
  25. package/build/cli/subagent-guard.js +3 -0
  26. package/build/cli/types.js +1 -1
  27. package/build/core/bind.js +111 -6
  28. package/build/core/draft.js +1 -1
  29. package/build/core/projection/generation/database.js +21 -2
  30. package/build/core/projection/generation/model.js +24 -3
  31. package/build/core/projection/generation/projection-generation-continuation.js +75 -10
  32. package/build/core/projection/generation/projection-generation-execution.js +4 -3
  33. package/build/core/projection/generation/projection-generation-runner.js +96 -53
  34. package/build/core/projection/generation/projection-generation-runtime.js +19 -0
  35. package/build/core/projection/generation/store.js +9 -0
  36. package/build/core/projection/generation/transitions.js +105 -3
  37. package/build/core/projection/index.js +2 -2
  38. package/build/core/projection/projection-core.js +1 -1
  39. package/build/core/projection/projection-kill.js +31 -0
  40. package/build/core/projection/projection-life-protocol.js +10 -0
  41. package/build/core/projection/projection-wait.js +53 -15
  42. package/build/core/projection/projection-wake.js +35 -3
  43. package/build/core/projection/tell/database.js +18 -0
  44. package/build/core/projection/tell/model.js +1 -0
  45. package/build/core/projection/tell/store.js +102 -55
  46. package/build/core/registry.js +82 -69
  47. package/build/core/scope.js +9 -9
  48. package/build/core/task/index.js +2 -2
  49. package/build/core/task/task-contract.js +18 -0
  50. package/build/core/task/task-git-store.js +50 -0
  51. package/build/generated/version.js +2 -2
  52. package/package.json +1 -1
  53. package/skills/keiyaku-akuma/SKILL.md +10 -0
  54. package/skills/keiyaku-workflow/SKILL.md +76 -7
@@ -4,7 +4,7 @@ import { DatabaseSync } from "node:sqlite";
4
4
  import { configureHeartBusyTimeout } from "../heart.js";
5
5
  import { ProjectionGenerationStoreError, parseStoredRow, validateHistory, } from "./model.js";
6
6
  export const PROJECTION_GENERATION_STORE_FILE = "heart";
7
- export const PROJECTION_GENERATION_SCHEMA_VERSION = 2;
7
+ export const PROJECTION_GENERATION_SCHEMA_VERSION = 3;
8
8
  const SCHEMA_TABLE_SQL = `
9
9
  CREATE TABLE generation_schema (
10
10
  singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
@@ -14,7 +14,7 @@ const SCHEMA_TABLE_SQL = `
14
14
  const RECORD_TABLE_SQL = `
15
15
  CREATE TABLE generation_records (
16
16
  seq INTEGER PRIMARY KEY CHECK (seq > 0),
17
- kind TEXT NOT NULL CHECK (kind IN ('launch', 'adoption', 'kill-intent', 'verdict')),
17
+ kind TEXT NOT NULL CHECK (kind IN ('launch', 'adoption', 'kill-intent', 'interrupt-intent', 'verdict')),
18
18
  execution_id TEXT NOT NULL CHECK (length(trim(execution_id)) > 0),
19
19
  facts_json TEXT NOT NULL CHECK (json_valid(facts_json))
20
20
  ) STRICT
@@ -250,6 +250,25 @@ export function openReadOnlyDatabase(projectionDirectory) {
250
250
  throw projectionGenerationStoreReadError(databasePath, error);
251
251
  }
252
252
  }
253
+ /** Read the validated heart through one consistent SQLite snapshot. */
254
+ export function withGenerationReadSnapshot(projectionDirectory, read) {
255
+ const { database } = openReadOnlyDatabase(projectionDirectory);
256
+ try {
257
+ database.exec("BEGIN");
258
+ try {
259
+ const result = read(database);
260
+ database.exec("COMMIT");
261
+ return result;
262
+ }
263
+ catch (error) {
264
+ rollbackQuietly(database);
265
+ throw error;
266
+ }
267
+ }
268
+ finally {
269
+ closeDatabase(database);
270
+ }
271
+ }
253
272
  export function assertDatabaseOpen(database) {
254
273
  if (closedDatabases.has(database))
255
274
  throw new ProjectionGenerationStoreError("projection generation store is closed");
@@ -133,6 +133,16 @@ export function validateKillIntentFacts(value, location) {
133
133
  throw new ProjectionGenerationStoreError(`${location}.state is reserved for verdict records`);
134
134
  return facts;
135
135
  }
136
+ export function validateInterruptIntentFacts(value, location) {
137
+ const facts = cloneFacts(value, location);
138
+ facts.interruptedAt = assertIsoTimestamp(facts.interruptedAt, `${location}.interruptedAt`);
139
+ if ("operatorAction" in facts) {
140
+ throw new ProjectionGenerationStoreError(`${location}.operatorAction is not part of interrupt-intent`);
141
+ }
142
+ if ("state" in facts)
143
+ throw new ProjectionGenerationStoreError(`${location}.state is reserved for verdict records`);
144
+ return facts;
145
+ }
136
146
  export function validateVerdictFacts(value, location) {
137
147
  const facts = cloneFacts(value, location);
138
148
  if (typeof facts.state !== "string" || !VERDICTS.has(facts.state)) {
@@ -182,6 +192,7 @@ export function parseStoredRow(row) {
182
192
  if (row.kind !== "launch"
183
193
  && row.kind !== "adoption"
184
194
  && row.kind !== "kill-intent"
195
+ && row.kind !== "interrupt-intent"
185
196
  && row.kind !== "verdict") {
186
197
  throw new ProjectionGenerationStoreError(`generation row ${seq} has unknown kind '${row.kind}'`);
187
198
  }
@@ -200,7 +211,9 @@ export function parseStoredRow(row) {
200
211
  ? validateAdoptionFacts(decoded, location)
201
212
  : row.kind === "kill-intent"
202
213
  ? validateKillIntentFacts(decoded, location)
203
- : validateVerdictFacts(decoded, location);
214
+ : row.kind === "interrupt-intent"
215
+ ? validateInterruptIntentFacts(decoded, location)
216
+ : validateVerdictFacts(decoded, location);
204
217
  return { seq, kind: row.kind, executionId, facts };
205
218
  }
206
219
  export function validateHistory(records) {
@@ -237,13 +250,21 @@ export function validateHistory(records) {
237
250
  continue;
238
251
  }
239
252
  if (record.kind === "kill-intent") {
240
- if (current.killIntent)
241
- throw new ProjectionGenerationStoreError(`generation row ${record.seq} duplicates kill-intent`);
253
+ if (current.killIntent || current.interruptIntent)
254
+ throw new ProjectionGenerationStoreError(`generation row ${record.seq} duplicates generation stop intent`);
242
255
  if (current.verdict)
243
256
  throw new ProjectionGenerationStoreError(`generation row ${record.seq} records kill-intent after verdict`);
244
257
  current.killIntent = record;
245
258
  continue;
246
259
  }
260
+ if (record.kind === "interrupt-intent") {
261
+ if (current.killIntent || current.interruptIntent)
262
+ throw new ProjectionGenerationStoreError(`generation row ${record.seq} duplicates generation stop intent`);
263
+ if (current.verdict)
264
+ throw new ProjectionGenerationStoreError(`generation row ${record.seq} records interrupt-intent after verdict`);
265
+ current.interruptIntent = record;
266
+ continue;
267
+ }
247
268
  if (current.verdict)
248
269
  throw new ProjectionGenerationStoreError(`generation row ${record.seq} duplicates verdict`);
249
270
  current.verdict = record;
@@ -1,22 +1,87 @@
1
1
  import { ProjectionStateError } from "../../atomic-publish.js";
2
2
  import { applySnapshotEffort, parseResolvedAkumaLaunchSnapshot, } from "../../../agents/launch-snapshot/model.js";
3
- import { listTellIds, readTellOriginal, readTellSubmitted, } from "../projection-core.js";
4
- export function snapshotPendingTells(projectionDirectory) {
5
- const pending = ["inbox", "inflight", "submitted"]
6
- .flatMap((layer) => listTellIds(projectionDirectory, layer)
7
- .map((tellId) => ({ layer, tellId })))
8
- .sort((left, right) => left.tellId.localeCompare(right.tellId));
3
+ import { listTellMetadataFromDatabase, readPendingTellWindowFromDatabase, } from "../tell/store.js";
4
+ import { PENDING_PROJECTION_TELL_STATES } from "../tell/model.js";
5
+ import { readValidatedHistory, withGenerationReadSnapshot } from "./database.js";
6
+ function retainedFenceFor(current) {
7
+ return current?.verdict?.facts.state === "launch-failed"
8
+ ? current.launch.facts.tellFence ?? []
9
+ : [];
10
+ }
11
+ function interruptedForReplay(current) {
12
+ return current?.verdict?.facts.state === "interrupted";
13
+ }
14
+ function snapshotTells(database, layers, admit) {
15
+ const pending = listTellMetadataFromDatabase(database, layers)
16
+ .filter((item) => admit?.({
17
+ layer: item.state,
18
+ tellId: item.tellId,
19
+ ...(item.executionId ? { executionId: item.executionId } : {}),
20
+ }) ?? true);
9
21
  const effort = pending.reduce((selected, item) => {
10
- const tell = item.layer === "submitted"
11
- ? readTellSubmitted(projectionDirectory, item.tellId)
12
- : readTellOriginal(projectionDirectory, item.layer, item.tellId);
13
- return tell.effort ?? selected;
22
+ return item.effort ?? selected;
14
23
  }, undefined);
15
24
  return {
16
25
  tellIds: pending.map((item) => item.tellId),
17
26
  ...(effort ? { effort } : {}),
18
27
  };
19
28
  }
29
+ /** Ordinary completed-generation handoff retains only tells not yet delivered. */
30
+ export function snapshotPendingTells(projectionDirectory) {
31
+ return withGenerationReadSnapshot(projectionDirectory, (database) => snapshotTells(database, PENDING_PROJECTION_TELL_STATES));
32
+ }
33
+ /** Interrupted generations replay pending mail and their own delivered tells. */
34
+ export function snapshotInterruptTells(projectionDirectory, executionId) {
35
+ return withGenerationReadSnapshot(projectionDirectory, (database) => snapshotTells(database, [...PENDING_PROJECTION_TELL_STATES, "delivered"], (item) => item.layer !== "delivered" || item.executionId === executionId));
36
+ }
37
+ /** Retry ordinary pending mail plus exactly the durable fence of a failed launch. */
38
+ export function snapshotRetainedSuccessorTells(projectionDirectory, retainedFence) {
39
+ const retained = new Set(retainedFence);
40
+ return withGenerationReadSnapshot(projectionDirectory, (database) => snapshotTells(database, [...PENDING_PROJECTION_TELL_STATES, "delivered"], ({ layer, tellId }) => layer !== "delivered" || retained.has(tellId)));
41
+ }
42
+ /** Build the durable successor fence only after continuation is admitted. */
43
+ export function snapshotProjectionContinuationTells(projectionDirectory, current) {
44
+ const snapshot = readProjectionGenerationContinuationSnapshot(projectionDirectory, 0);
45
+ if (snapshot.current?.launch.executionId !== current?.launch.executionId) {
46
+ throw new ProjectionStateError("projection generation changed while selecting continuation tells");
47
+ }
48
+ return snapshot.successor;
49
+ }
50
+ function readContinuationFromDatabase(database, current, limit = 10) {
51
+ const tells = readPendingTellWindowFromDatabase(database, limit);
52
+ const retainedFence = retainedFenceFor(current);
53
+ const successor = interruptedForReplay(current)
54
+ ? snapshotTells(database, [...PENDING_PROJECTION_TELL_STATES, "delivered"], (item) => item.layer !== "delivered" || item.executionId === current.launch.executionId)
55
+ : retainedFence.length > 0
56
+ ? snapshotTells(database, [...PENDING_PROJECTION_TELL_STATES, "delivered"], ({ layer, tellId }) => layer !== "delivered" || retainedFence.includes(tellId))
57
+ : snapshotTells(database, PENDING_PROJECTION_TELL_STATES);
58
+ return {
59
+ tells,
60
+ successor,
61
+ requiresWake: successor.tellIds.length > 0,
62
+ };
63
+ }
64
+ /** One heart snapshot owns generation selection and all continuation facts. */
65
+ export function readProjectionGenerationContinuationSnapshot(projectionDirectory, limit = 10) {
66
+ return withGenerationReadSnapshot(projectionDirectory, (database) => {
67
+ const { records, current } = readValidatedHistory(database);
68
+ const continuation = readContinuationFromDatabase(database, current, limit);
69
+ return {
70
+ records,
71
+ current,
72
+ generationSequence: records.at(-1)?.seq ?? 0,
73
+ ...continuation,
74
+ };
75
+ });
76
+ }
77
+ /** One continuation eligibility fact shared by tell, wait, and wake. */
78
+ export function readProjectionContinuation(projectionDirectory, current, limit = 10) {
79
+ const snapshot = readProjectionGenerationContinuationSnapshot(projectionDirectory, limit);
80
+ if (snapshot.current?.launch.executionId !== current?.launch.executionId) {
81
+ throw new ProjectionStateError("projection generation changed while reading continuation");
82
+ }
83
+ return snapshot;
84
+ }
20
85
  export function applyContinuationEffort(inputs, effort) {
21
86
  if (!effort)
22
87
  return inputs;
@@ -2,7 +2,7 @@ import { initializeConfig } from "../../../config/env.js";
2
2
  import { buildCallTerms } from "../../../agents/call-terms.js";
3
3
  import { parseResolvedAkumaLaunchSnapshot } from "../../../agents/launch-snapshot/model.js";
4
4
  import { revive, call, } from "../../../agents/harness/index.js";
5
- import { claimFencedTells, demoteSubmittedTellsForReplay, readTellOriginal, readProjectionEvents, } from "../projection-core.js";
5
+ import { claimFencedTells, readTellOriginal, readProjectionEvents, } from "../projection-core.js";
6
6
  import { createProjectionExecutionObserver, renderProjectionTellFrame, } from "../projection-execution-observer.js";
7
7
  import { readProjectionIdentity } from "../projection-identity.js";
8
8
  import { selectValidStoredAgentEvents } from "../../stored-agent-event.js";
@@ -140,7 +140,6 @@ export function executeProjectionGeneration(projectionDirectory, launch, provide
140
140
  const inputs = parseLaunchInputs(launch);
141
141
  initializeConfig(process.env, inputs.cwd);
142
142
  const executionId = launch.executionId;
143
- demoteSubmittedTellsForReplay(projectionDirectory, executionId);
144
143
  const initialTellIds = claimFencedTells(projectionDirectory, launch.facts.tellFence ?? [], executionId);
145
144
  const tellPrompt = initialTellIds.map((tellId) => {
146
145
  const tell = readTellOriginal(projectionDirectory, "inflight", tellId);
@@ -182,7 +181,9 @@ export function executeProjectionGeneration(projectionDirectory, launch, provide
182
181
  ? "completed"
183
182
  : result.status === "dismissed"
184
183
  ? "dismissed"
185
- : "failed",
184
+ : result.status === "interrupted"
185
+ ? "interrupted"
186
+ : "failed",
186
187
  facts: await completionFacts(projectionDirectory, executionId, inputs, result),
187
188
  };
188
189
  }
@@ -76,6 +76,7 @@ export async function runProjectionGeneration(input) {
76
76
  let settled = false;
77
77
  let tellObserver;
78
78
  let graceTimer;
79
+ let intentTimer;
79
80
  const clearGraceTimer = () => {
80
81
  if (graceTimer === undefined)
81
82
  return;
@@ -103,7 +104,8 @@ export async function runProjectionGeneration(input) {
103
104
  if (current?.launch.executionId !== input.executionId) {
104
105
  throw new Error("adopted generation stopped being current");
105
106
  }
106
- // Pre-admission kill: skip provider work and commit killed directly.
107
+ // Pre-admission kill retains its established settlement law. Interrupt
108
+ // intent must enter provider execution so only an actual stop can settle it.
107
109
  if (current.killIntent) {
108
110
  const killed = store.runnerKilledIfRequested({ executionId: input.executionId });
109
111
  if (killed.status !== "committed")
@@ -113,10 +115,46 @@ export async function runProjectionGeneration(input) {
113
115
  }
114
116
  const handle = input.execute(current.launch);
115
117
  tellObserver = openProjectionTellObserver(input.projectionDirectory);
118
+ const settleInterrupted = (facts = {}) => {
119
+ const transition = store.runnerInterruptedIfRequested({
120
+ executionId: input.executionId,
121
+ facts,
122
+ terminalAt: new Date(now()).toISOString(),
123
+ });
124
+ if (transition.status !== "committed")
125
+ return { status: "rejected" };
126
+ settled = true;
127
+ runnerLock.close();
128
+ runnerLock = undefined;
129
+ void input.wakeAfterInterrupt?.().catch(() => {
130
+ // The ordinary wake path persists its typed failure. A later tell can retry.
131
+ });
132
+ return { status: "completed" };
133
+ };
134
+ const settleRunnerOutcome = (execution) => {
135
+ const transition = store.runnerVerdictIfOpen({
136
+ executionId: input.executionId,
137
+ verdict: execution.verdict,
138
+ facts: execution.facts,
139
+ terminalAt: new Date(now()).toISOString(),
140
+ });
141
+ if (transition.status !== "committed")
142
+ return { status: "rejected" };
143
+ settled = true;
144
+ if (transition.records[0]?.facts.state === "interrupted") {
145
+ runnerLock.close();
146
+ runnerLock = undefined;
147
+ void input.wakeAfterInterrupt?.().catch(() => {
148
+ // The ordinary wake path persists its typed failure. A later observer retries.
149
+ });
150
+ }
151
+ return { status: "completed" };
152
+ };
116
153
  let gracefulAbortInvoked = false;
117
154
  let forceAbortInvoked = false;
118
155
  let sawKillIntent = false;
119
- const observeKillBoundary = () => {
156
+ let sawInterruptIntent = false;
157
+ const observeStopBoundary = () => {
120
158
  try {
121
159
  if (!tellObserver.hasDataVersionChanged())
122
160
  return;
@@ -125,9 +163,10 @@ export async function runProjectionGeneration(input) {
125
163
  return;
126
164
  if (live.verdict)
127
165
  return;
128
- if (!live.killIntent)
166
+ sawKillIntent = live.killIntent !== undefined;
167
+ sawInterruptIntent = live.interruptIntent !== undefined;
168
+ if (!sawKillIntent && !sawInterruptIntent)
129
169
  return;
130
- sawKillIntent = true;
131
170
  if (gracefulAbortInvoked)
132
171
  return;
133
172
  gracefulAbortInvoked = true;
@@ -153,12 +192,12 @@ export async function runProjectionGeneration(input) {
153
192
  const outcomeWatch = handle.outcome.then((execution) => {
154
193
  outcomeBox.status = "fulfilled";
155
194
  outcomeBox.execution = execution;
156
- observeKillBoundary();
195
+ observeStopBoundary();
157
196
  bumpActivity();
158
197
  }, (error) => {
159
198
  outcomeBox.status = "rejected";
160
199
  outcomeBox.error = error;
161
- observeKillBoundary();
200
+ observeStopBoundary();
162
201
  bumpActivity();
163
202
  });
164
203
  // Always rejection-observe so abandon paths never surface unhandled rejections.
@@ -168,7 +207,7 @@ export async function runProjectionGeneration(input) {
168
207
  for await (const _event of handle.events) {
169
208
  if (forceAbortInvoked)
170
209
  return;
171
- observeKillBoundary();
210
+ observeStopBoundary();
172
211
  bumpActivity();
173
212
  }
174
213
  }
@@ -178,10 +217,17 @@ export async function runProjectionGeneration(input) {
178
217
  bumpActivity();
179
218
  })();
180
219
  observeAbandoned(eventDrain);
181
- while (outcomeBox.status === "pending" && !sawKillIntent) {
220
+ // A provider may be silently executing when an operator interrupts it.
221
+ // The durable observer is the sole stop-intent authority in that interval.
222
+ intentTimer = setInterval(() => {
223
+ observeStopBoundary();
224
+ bumpActivity();
225
+ }, 100);
226
+ intentTimer.unref?.();
227
+ while (outcomeBox.status === "pending" && !sawKillIntent && !sawInterruptIntent) {
182
228
  await activityChanged();
183
229
  }
184
- if (sawKillIntent) {
230
+ if (sawKillIntent || sawInterruptIntent) {
185
231
  // Grace race: outcome settlement inside the window is stop proof.
186
232
  if (outcomeBox.status === "pending") {
187
233
  const graceExpired = new Promise((resolve) => {
@@ -201,6 +247,16 @@ export async function runProjectionGeneration(input) {
201
247
  const facts = killOutcome.status === "fulfilled"
202
248
  ? nonReservedRunnerKillFacts(killOutcome.execution.facts)
203
249
  : undefined;
250
+ if (sawInterruptIntent && killOutcome.status === "fulfilled" && killOutcome.execution.verdict === "interrupted") {
251
+ return settleInterrupted(facts);
252
+ }
253
+ if (sawInterruptIntent) {
254
+ if (killOutcome.status === "fulfilled")
255
+ return settleRunnerOutcome(killOutcome.execution);
256
+ if (killOutcome.status === "rejected")
257
+ throw killOutcome.error;
258
+ throw new Error("interrupt outcome did not establish provider stop");
259
+ }
204
260
  const killed = store.runnerKilledIfRequested({
205
261
  executionId: input.executionId,
206
262
  ...(facts && Object.keys(facts).length > 0 ? { facts } : {}),
@@ -214,6 +270,8 @@ export async function runProjectionGeneration(input) {
214
270
  forceAbortInvoked = true;
215
271
  try {
216
272
  await handle.abort("force");
273
+ if (sawInterruptIntent)
274
+ return settleInterrupted();
217
275
  const killed = store.runnerKilledIfRequested({ executionId: input.executionId });
218
276
  if (killed.status !== "committed")
219
277
  return { status: "rejected" };
@@ -225,7 +283,7 @@ export async function runProjectionGeneration(input) {
225
283
  executionId: input.executionId,
226
284
  verdict: "failed",
227
285
  facts: {
228
- operatorAction: "kill",
286
+ ...(sawKillIntent ? { operatorAction: "kill" } : {}),
229
287
  diagnostic: "escalation-failed",
230
288
  detail: errorDetail(error),
231
289
  failedAt: new Date(now()).toISOString(),
@@ -268,53 +326,36 @@ export async function runProjectionGeneration(input) {
268
326
  successorFacts: successorLaunchFacts(current.launch, execution.facts, pending.effort, now),
269
327
  tellFence,
270
328
  });
271
- if (transition.status !== "committed")
272
- return { status: "rejected" };
273
- settled = true;
274
- // Handoff makes the predecessor terminal before life authority moves.
275
- // A held projection lock must always mean that a current runner exists,
276
- // so release it before the successor makes its one-shot acquisition.
277
- runnerLock.close();
278
- runnerLock = undefined;
279
- try {
280
- input.spawnSuccessor?.(successorExecutionId);
281
- }
282
- catch (error) {
283
- store.verdictIfOpen({
284
- executionId: successorExecutionId,
285
- verdict: "launch-failed",
286
- facts: {
287
- stage: "spawn",
288
- detail: `successor runner spawn failed: ${errorDetail(error)}`,
289
- terminalAt: new Date(now()).toISOString(),
290
- },
291
- });
292
- }
293
- return { status: "handed-off", successorExecutionId };
294
- }
295
- const verdict = store.verdictIfOpen({
296
- executionId: input.executionId,
297
- verdict: execution.verdict,
298
- facts: execution.facts,
299
- });
300
- if (verdict.status !== "committed") {
301
- // Kill intent may commit after the last outcome-boundary observation and
302
- // before this transaction. The settled outcome is already stop proof.
303
- if (verdict.reason === "kill-requested") {
304
- const facts = nonReservedRunnerKillFacts(execution.facts);
305
- const killed = store.runnerKilledIfRequested({
306
- executionId: input.executionId,
307
- ...(Object.keys(facts).length > 0 ? { facts } : {}),
308
- });
309
- if (killed.status !== "committed")
329
+ if (transition.status !== "committed") {
330
+ if (transition.reason !== "kill-requested" && transition.reason !== "interrupt-requested") {
310
331
  return { status: "rejected" };
332
+ }
333
+ }
334
+ else {
311
335
  settled = true;
312
- return { status: "completed" };
336
+ // Handoff makes the predecessor terminal before life authority moves.
337
+ // A held projection lock must always mean that a current runner exists,
338
+ // so release it before the successor makes its one-shot acquisition.
339
+ runnerLock.close();
340
+ runnerLock = undefined;
341
+ try {
342
+ input.spawnSuccessor?.(successorExecutionId);
343
+ }
344
+ catch (error) {
345
+ store.verdictIfOpen({
346
+ executionId: successorExecutionId,
347
+ verdict: "launch-failed",
348
+ facts: {
349
+ stage: "spawn",
350
+ detail: `successor runner spawn failed: ${errorDetail(error)}`,
351
+ terminalAt: new Date(now()).toISOString(),
352
+ },
353
+ });
354
+ }
355
+ return { status: "handed-off", successorExecutionId };
313
356
  }
314
- return { status: "rejected" };
315
357
  }
316
- settled = true;
317
- return { status: "completed" };
358
+ return settleRunnerOutcome(execution);
318
359
  }
319
360
  catch (error) {
320
361
  if (adopted && !settled) {
@@ -338,6 +379,8 @@ export async function runProjectionGeneration(input) {
338
379
  }
339
380
  finally {
340
381
  clearGraceTimer();
382
+ if (intentTimer !== undefined)
383
+ clearInterval(intentTimer);
341
384
  try {
342
385
  tellObserver?.close();
343
386
  }
@@ -2,6 +2,24 @@ import { executeProjectionGeneration } from "./projection-generation-execution.j
2
2
  import { spawnProjectionGenerationRunner } from "./projection-generation-process.js";
3
3
  import { runProjectionGeneration } from "./projection-generation-runner.js";
4
4
  import { snapshotPendingTells } from "./projection-generation-continuation.js";
5
+ import { recordInterruptWakeFailure, tellProjection } from "../projection-wake.js";
6
+ import { readProjectionIdentity } from "../projection-identity.js";
7
+ import { FlowError } from "../../../flow-error.js";
8
+ import * as path from "node:path";
9
+ export async function wakeProjectionAfterInterrupt(projectionDirectory, wake = tellProjection) {
10
+ const pact = readProjectionIdentity(projectionDirectory);
11
+ try {
12
+ await wake(projectionDirectory, `${pact.akuma}/${path.basename(projectionDirectory)}`, undefined);
13
+ }
14
+ catch (error) {
15
+ const cause = error instanceof FlowError
16
+ && error.facts?.kind === "projection_wake_failure"
17
+ ? error.facts.cause
18
+ : "spawn";
19
+ recordInterruptWakeFailure(projectionDirectory, cause);
20
+ throw error;
21
+ }
22
+ }
5
23
  export async function runProjectionGenerationRuntime(projectionDirectory, executionId) {
6
24
  await runProjectionGeneration({
7
25
  projectionDirectory,
@@ -11,5 +29,6 @@ export async function runProjectionGenerationRuntime(projectionDirectory, execut
11
29
  spawnSuccessor: (successorExecutionId) => {
12
30
  spawnProjectionGenerationRunner(projectionDirectory, successorExecutionId);
13
31
  },
32
+ wakeAfterInterrupt: () => wakeProjectionAfterInterrupt(projectionDirectory),
14
33
  });
15
34
  }
@@ -46,9 +46,18 @@ export class ProjectionGenerationStore {
46
46
  requestKillIfOpen(input) {
47
47
  return transitions.requestKillIfOpen(this.#database, this.readOnly, input);
48
48
  }
49
+ requestInterruptIfOpen(input) {
50
+ return transitions.requestInterruptIfOpen(this.#database, this.readOnly, input);
51
+ }
49
52
  runnerKilledIfRequested(input) {
50
53
  return transitions.runnerKilledIfRequested(this.#database, this.readOnly, input);
51
54
  }
55
+ runnerInterruptedIfRequested(input) {
56
+ return transitions.runnerInterruptedIfRequested(this.#database, this.readOnly, input);
57
+ }
58
+ runnerVerdictIfOpen(input) {
59
+ return transitions.runnerVerdictIfOpen(this.#database, this.readOnly, input);
60
+ }
52
61
  settleKillIfRunnerDead(input) {
53
62
  return transitions.settleKillIfRunnerDead(this.#database, this.readOnly, input);
54
63
  }