@osolmaz/pi-workflows 0.13.3 → 0.14.0

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 (48) hide show
  1. package/README.md +136 -118
  2. package/dist/builtins/autoimplement.workflow.d.ts +12 -12
  3. package/dist/builtins/autoplan.workflow.d.ts +3 -3
  4. package/dist/builtins/autoplan.workflow.js +44 -35
  5. package/dist/builtins/autoplan.workflow.js.map +1 -1
  6. package/dist/builtins/catalog.js +1 -1
  7. package/dist/builtins/plan-change.workflow.d.ts +6 -6
  8. package/dist/controllers/index.d.ts +1 -1
  9. package/dist/controllers/index.js.map +1 -1
  10. package/dist/controllers/sqlite.d.ts +34 -31
  11. package/dist/controllers/sqlite.js +116 -77
  12. package/dist/controllers/sqlite.js.map +1 -1
  13. package/dist/extension/index.js +721 -202
  14. package/dist/extension/index.js.map +1 -1
  15. package/dist/extension/restart-policy.d.ts +38 -0
  16. package/dist/extension/restart-policy.js +116 -0
  17. package/dist/extension/restart-policy.js.map +1 -0
  18. package/dist/extension/terminal-decision.d.ts +51 -0
  19. package/dist/extension/terminal-decision.js +110 -0
  20. package/dist/extension/terminal-decision.js.map +1 -0
  21. package/dist/state/prune.js +36 -10
  22. package/dist/state/prune.js.map +1 -1
  23. package/dist/workflows/tool-input.d.ts +4 -0
  24. package/dist/workflows/tool-input.js +6 -1
  25. package/dist/workflows/tool-input.js.map +1 -1
  26. package/docs/2026-08-25-workflow-follow-ups.md +8 -6
  27. package/docs/DEFERRED_TURNS.md +39 -26
  28. package/docs/HUMAN_DECISIONS.md +12 -4
  29. package/docs/SQLITE_STATE.md +24 -0
  30. package/docs/WORKFLOW_COMPOSITION.md +1 -1
  31. package/docs/plans/2026-08-19-human-decision-gates-plan.md +34 -8
  32. package/docs/plans/2026-08-27-workflow-terminal-restart-plan.md +357 -0
  33. package/docs/workflows.md +85 -29
  34. package/herdr-plugin.toml +1 -1
  35. package/package.json +1 -1
  36. package/skills/autodoc/SKILL.md +1 -1
  37. package/skills/autoimplement/SKILL.md +1 -1
  38. package/skills/autoplan/SKILL.md +6 -6
  39. package/skills/pi-workflows/SKILL.md +2 -0
  40. package/src/builtins/autoplan.workflow.ts +45 -37
  41. package/src/builtins/catalog.ts +1 -1
  42. package/src/controllers/index.ts +3 -0
  43. package/src/controllers/sqlite.ts +226 -155
  44. package/src/extension/index.ts +881 -220
  45. package/src/extension/restart-policy.ts +163 -0
  46. package/src/extension/terminal-decision.ts +172 -0
  47. package/src/state/prune.ts +35 -9
  48. package/src/workflows/tool-input.ts +9 -1
@@ -96,9 +96,11 @@ function originalUserInstructions(outputs: Record<string, unknown>): string {
96
96
  }
97
97
 
98
98
  function originalInstructionsPrompt(outputs: Record<string, unknown>): string {
99
- return ["Original user instructions (authoritative):", originalUserInstructions(outputs)].join(
100
- "\n",
101
- );
99
+ return [
100
+ "Continue in this Pi session. Do not delegate this workflow step or start another session.",
101
+ "Original user instructions (authoritative):",
102
+ originalUserInstructions(outputs),
103
+ ].join("\n");
102
104
  }
103
105
 
104
106
  function requireString(value: unknown, label: string): string {
@@ -286,7 +288,7 @@ function candidateSummary(
286
288
  ideal: AutoplanIdeal,
287
289
  id: string,
288
290
  ): { id: string; title: string; gist: string } {
289
- if (id === "ideal") return { id, title: "Ideal end state", gist: ideal.ideal };
291
+ if (id === "ideal") return { id, title: "Holy grail", gist: ideal.ideal };
290
292
  const candidate = proposal.candidates.find((item) => item.id === id);
291
293
  if (candidate === undefined)
292
294
  throw new Error(`autoplan candidate ${JSON.stringify(id)} is missing`);
@@ -298,9 +300,9 @@ function summaryInput(
298
300
  blocked: boolean,
299
301
  input: AutoplanInput,
300
302
  ): PlainSummaryInput {
301
- const proposal = outputs.propose as AutoplanProposal;
302
- const selection = outputs.choose as AutoplanSelection;
303
- const ideal = outputs.ideal as AutoplanIdeal;
303
+ const proposal = outputs.solutions as AutoplanProposal;
304
+ const selection = outputs.select as AutoplanSelection;
305
+ const ideal = outputs.holyGrail as AutoplanIdeal;
304
306
  const selected = candidateSummary(proposal, ideal, selection.selectedId);
305
307
  return {
306
308
  source: {
@@ -368,6 +370,7 @@ export const autoplanWorkflow = defineWorkflow({
368
370
  statusDetail: "capturing the user's instructions",
369
371
  prompt: () =>
370
372
  [
373
+ "Continue in this Pi session. Do not delegate this workflow step or start another session.",
371
374
  "Read the conversation that came before this workflow step.",
372
375
  "Return one text string named originalUserInstructions.",
373
376
  "Include everything that the user has instructed for the intended purpose in the given context.",
@@ -398,12 +401,13 @@ export const autoplanWorkflow = defineWorkflow({
398
401
  expectedOutput: `{ "problem": "concise statement", "success": ["criterion"], "inScope": ["change"], "outOfScope": ["change"], "constraints": ["constraint"], "controlBoundary": "what can change" }`,
399
402
  validate: (value) => requireRecord(value, "autoplan frame"),
400
403
  }),
401
- propose: agent({
402
- statusDetail: "devising candidate solutions",
404
+ solutions: agent({
405
+ statusDetail: "devising long-term solutions",
403
406
  prompt: ({ outputs, input }) =>
404
407
  [
405
408
  originalInstructionsPrompt(outputs),
406
409
  "Give two to four practical options that fit the allowed scope.",
410
+ "For each option, ask: Is this a Long term elegant and production ready solution?",
407
411
  "For each option return a stable lowercase id and a short title. Add a plain gist and full solution, explain the reason and trade-offs, and list the parts.",
408
412
  "Favor a few reusable parts with clear owners and use interfaces that already exist.",
409
413
  "Reject one-off machinery and infrastructure that the task does not need.",
@@ -416,42 +420,46 @@ export const autoplanWorkflow = defineWorkflow({
416
420
  validate: (value, { input }) =>
417
421
  parseProposal(value, (input as AutoplanInput).previousPlan !== undefined),
418
422
  }),
419
- ideal: agent({
420
- statusDetail: "describing the ideal end state",
423
+ holyGrail: agent({
424
+ statusDetail: "describing the Holy grail",
421
425
  prompt: ({ outputs, input }) =>
422
426
  [
423
427
  originalInstructionsPrompt(outputs),
424
- "Describe the best possible end state separately from the practical options.",
425
- "It may match one option or go beyond the current scope.",
428
+ "Describe the Holy grail separately from the practical options.",
429
+ "Ask: Is this the Holy grail for the problem?",
430
+ "The Holy grail may match one option or go beyond the current scope.",
431
+ "Explain what makes it more Long term elegant and production ready than the practical options.",
426
432
  "List each dependency we do not control. Do not assume that it can change.",
427
- "State what this end state would improve beyond the practical options.",
433
+ "State what the Holy grail would improve beyond the practical options.",
428
434
  `Problem frame: ${JSON.stringify(outputs.frame)}`,
429
- `Candidates: ${JSON.stringify(outputs.propose)}`,
435
+ `Candidates: ${JSON.stringify(outputs.solutions)}`,
430
436
  `New evidence: ${JSON.stringify((input as AutoplanInput).newEvidence ?? null)}`,
431
437
  ].join("\n"),
432
- expectedOutput: `{ "ideal": "ideal end state", "outsideDependencies": ["dependency"], "additionalValue": ["benefit"] }`,
438
+ expectedOutput: `{ "ideal": "Holy grail end state", "outsideDependencies": ["dependency"], "additionalValue": ["benefit"] }`,
433
439
  validate: parseIdeal,
434
440
  }),
435
- choose: agent({
436
- statusDetail: "choosing the practical solution",
441
+ select: agent({
442
+ statusDetail: "selecting the solution",
437
443
  prompt: ({ outputs }) =>
438
444
  [
439
445
  originalInstructionsPrompt(outputs),
440
446
  "Select one option. Do not ask the user to choose.",
441
- "Select the ideal only when it fits the allowed scope and is ready for production. Its value must justify the added complexity.",
442
- "Otherwise select the best option we can build now that still moves toward the ideal.",
447
+ "Select the most Long term elegant and production ready option that is proportionate, in scope, and implementable through interfaces we control.",
448
+ "Select the Holy grail when it meets those conditions and its value justifies the added complexity.",
449
+ "Otherwise select the strongest practical option we can build now with a clear path toward the Holy grail.",
443
450
  "Work outside our control does not by itself make the plan blocked.",
444
451
  "Do not require changes to an upstream project or an unrelated repository. Do not require a new service or resource without approval.",
445
452
  "When two options solve the problem equally well, choose the simpler one.",
446
- "Give one specific rejection reason for every option you do not select, including the ideal.",
453
+ "Give one specific rejection reason for every option you do not select, including the Holy grail.",
447
454
  "Return blocked only if no option inside the allowed scope can meet the success criteria.",
448
455
  `Frame: ${JSON.stringify(outputs.frame)}`,
449
- `Candidates: ${JSON.stringify(outputs.propose)}`,
450
- `Ideal candidate id: ideal`,
451
- `Ideal: ${JSON.stringify(outputs.ideal)}`,
456
+ `Candidates: ${JSON.stringify(outputs.solutions)}`,
457
+ `Holy grail candidate id: ideal`,
458
+ `Holy grail: ${JSON.stringify(outputs.holyGrail)}`,
452
459
  ].join("\n"),
453
460
  expectedOutput: `{ "status": "ready" | "blocked", "selectedId": "candidate-id-or-ideal", "why": "reason", "relationshipToIdeal": "relationship", "rejected": [{ "id": "other-id", "reason": "why it lost" }], "compromises": ["compromise"], "blocker": "required only when blocked" }`,
454
- validate: (value, { outputs }) => parseSelection(value, outputs.propose as AutoplanProposal),
461
+ validate: (value, { outputs }) =>
462
+ parseSelection(value, outputs.solutions as AutoplanProposal),
455
463
  }),
456
464
  plan: agent({
457
465
  timeoutMs: 30 * 60_000,
@@ -466,8 +474,8 @@ export const autoplanWorkflow = defineWorkflow({
466
474
  "Correct the earlier plan when the new evidence proves it wrong.",
467
475
  "Do not change files.",
468
476
  `Frame: ${JSON.stringify(outputs.frame)}`,
469
- `Selection: ${JSON.stringify(outputs.choose)}`,
470
- `Candidates: ${JSON.stringify(outputs.propose)}`,
477
+ `Selection: ${JSON.stringify(outputs.select)}`,
478
+ `Candidates: ${JSON.stringify(outputs.solutions)}`,
471
479
  `Previous plan: ${JSON.stringify((input as AutoplanInput).previousPlan ?? null)}`,
472
480
  `New evidence: ${JSON.stringify((input as AutoplanInput).newEvidence ?? null)}`,
473
481
  ].join("\n"),
@@ -476,13 +484,13 @@ export const autoplanWorkflow = defineWorkflow({
476
484
  }),
477
485
  blocked: compute({
478
486
  run: ({ outputs }) => {
479
- const selection = outputs.choose as AutoplanSelection;
487
+ const selection = outputs.select as AutoplanSelection;
480
488
  return {
481
489
  status: "blocked",
482
490
  originalUserInstructions: originalUserInstructions(outputs),
483
491
  frame: outputs.frame,
484
- proposal: outputs.propose as AutoplanProposal,
485
- ideal: outputs.ideal as AutoplanIdeal,
492
+ proposal: outputs.solutions as AutoplanProposal,
493
+ ideal: outputs.holyGrail as AutoplanIdeal,
486
494
  selection,
487
495
  plainSummary: plainSummaryResult(outputs.blockedSummary, "autoplan blocked summary"),
488
496
  reason: requireString(selection.blocker, "autoplan blocker"),
@@ -499,9 +507,9 @@ export const autoplanWorkflow = defineWorkflow({
499
507
  status: "ready",
500
508
  originalUserInstructions: originalUserInstructions(outputs),
501
509
  frame: outputs.frame,
502
- proposal: outputs.propose as AutoplanProposal,
503
- ideal: outputs.ideal as AutoplanIdeal,
504
- selection: outputs.choose as AutoplanSelection,
510
+ proposal: outputs.solutions as AutoplanProposal,
511
+ ideal: outputs.holyGrail as AutoplanIdeal,
512
+ selection: outputs.select as AutoplanSelection,
505
513
  plan: outputs.plan,
506
514
  plainSummary: plainSummaryResult(outputs.readySummary, "autoplan ready summary"),
507
515
  planDigest,
@@ -513,11 +521,11 @@ export const autoplanWorkflow = defineWorkflow({
513
521
  },
514
522
  edges: [
515
523
  { from: "captureIntent", to: "frame" },
516
- { from: "frame", to: "propose" },
517
- { from: "propose", to: "ideal" },
518
- { from: "ideal", to: "choose" },
524
+ { from: "frame", to: "solutions" },
525
+ { from: "solutions", to: "holyGrail" },
526
+ { from: "holyGrail", to: "select" },
519
527
  {
520
- from: "choose",
528
+ from: "select",
521
529
  switch: { on: "$.status", cases: { ready: "plan", blocked: "blockedSummary" } },
522
530
  },
523
531
  { from: "plan", to: "readySummary" },
@@ -9,7 +9,7 @@ import sanityCheckWorkflow from "./sanity-check.workflow.js";
9
9
 
10
10
  export const builtinWorkflowCatalog = new BuiltinWorkflowCatalog([
11
11
  { id: "plain-summary", revision: "3", definition: plainSummaryWorkflow },
12
- { id: "autoplan", revision: "5", definition: autoplanWorkflow },
12
+ { id: "autoplan", revision: "6", definition: autoplanWorkflow },
13
13
  { id: "autodoc", revision: "2", definition: autodocWorkflow },
14
14
  { id: "autoimplement", revision: "11", definition: autoimplementWorkflow },
15
15
  { id: "plan-approval", revision: "4", definition: planApprovalWorkflow },
@@ -24,7 +24,10 @@ export {
24
24
  SqliteControllerStore,
25
25
  type RunEventRecord,
26
26
  type WorkflowNotificationRecord,
27
+ type WorkflowRunClaimOptions,
28
+ type WorkflowRunPreparationResult,
27
29
  type WorkflowRunQueueRecord,
30
+ type WorkflowRunReservationOptions,
28
31
  } from "./sqlite.js";
29
32
  export {
30
33
  type ControllerStore,
@@ -2,6 +2,7 @@ import { createHash, randomBytes, randomUUID } from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { StateDatabase, workflowStatePath } from "../state/database.js";
5
+ import { canonicalJson } from "../state/json.js";
5
6
  import { resourceIdFor, tokenHash } from "../state/mutation.js";
6
7
  import {
7
8
  EffectRequestConflictError,
@@ -39,6 +40,26 @@ export type WorkflowRunLaunchStatus =
39
40
  | "failed"
40
41
  | "cancelled";
41
42
 
43
+ export type WorkflowRunReservationOptions = {
44
+ runId: string;
45
+ workflowName: string;
46
+ workflowSourceRef: string;
47
+ workflowSource: unknown;
48
+ definitionDigest: string;
49
+ definitionSnapshot: unknown;
50
+ input: unknown;
51
+ launchOptions?: unknown;
52
+ runnerId: string;
53
+ originSessionId: string;
54
+ parentRunId?: string;
55
+ now?: string;
56
+ };
57
+
58
+ export type WorkflowRunClaimOptions = WorkflowRunReservationOptions & {
59
+ claimToken: string;
60
+ leaseMs: number;
61
+ };
62
+
42
63
  export type WorkflowRunQueueRecord = {
43
64
  runId: string;
44
65
  workflowName: string;
@@ -64,6 +85,16 @@ export type WorkflowRunQueueRecord = {
64
85
  finishedAt: string | null;
65
86
  };
66
87
 
88
+ export type WorkflowRunPreparationResult =
89
+ | {
90
+ state: "claimed";
91
+ run: WorkflowRunQueueRecord & { claimToken: string };
92
+ }
93
+ | {
94
+ state: "adopted";
95
+ run: WorkflowRunQueueRecord;
96
+ };
97
+
67
98
  export type WorkflowNotificationRecord = {
68
99
  notificationId: string;
69
100
  runId: string;
@@ -84,7 +115,9 @@ export type WorkflowTurnIntentCause =
84
115
  | "failed"
85
116
  | "launchFailed"
86
117
  | "controllerInterrupted"
87
- | "claimLost";
118
+ | "claimLost"
119
+ | "terminal"
120
+ | "cancelled";
88
121
 
89
122
  export type WorkflowTurnIntentResolution = "workflowPrompt" | "presentation" | "fallback";
90
123
 
@@ -996,139 +1029,147 @@ export class SqliteControllerStore implements ControllerStore {
996
1029
  }));
997
1030
  }
998
1031
 
999
- reserveWorkflowRun(options: {
1000
- runId: string;
1001
- workflowName: string;
1002
- workflowSourceRef: string;
1003
- workflowSource: unknown;
1004
- definitionDigest: string;
1005
- definitionSnapshot: unknown;
1006
- input: unknown;
1007
- launchOptions?: unknown;
1008
- runnerId: string;
1009
- originSessionId: string;
1010
- parentRunId?: string;
1011
- now?: string;
1012
- }): WorkflowRunQueueRecord {
1032
+ reserveWorkflowRun(options: WorkflowRunReservationOptions): WorkflowRunQueueRecord {
1013
1033
  validateRunId(options.runId);
1014
1034
  const now = epoch(validTimestamp(options.now));
1015
1035
  const definitionDigest = digestBuffer(options.definitionDigest);
1016
- return this.state.transaction(() => {
1017
- if (this.getWorkflowRun(options.runId) !== undefined) {
1018
- throw new Error(`Workflow run already reserved: ${options.runId}`);
1036
+ return this.state.transaction(() =>
1037
+ this.reserveWorkflowRunInTransaction(options, now, definitionDigest),
1038
+ );
1039
+ }
1040
+
1041
+ private reserveWorkflowRunInTransaction(
1042
+ options: WorkflowRunReservationOptions,
1043
+ now: number,
1044
+ definitionDigest: Buffer,
1045
+ ): WorkflowRunQueueRecord {
1046
+ if (this.getWorkflowRun(options.runId) !== undefined) {
1047
+ throw new Error(`Workflow run already reserved: ${options.runId}`);
1048
+ }
1049
+ if (options.parentRunId !== undefined) {
1050
+ const parent = this.requireWorkflowRunRow(options.parentRunId);
1051
+ if (parent.originSessionId !== options.originSessionId) {
1052
+ throw new Error("Continuation parent belongs to another Pi session");
1019
1053
  }
1020
- if (options.parentRunId !== undefined) {
1021
- const parent = this.requireWorkflowRunRow(options.parentRunId);
1022
- if (parent.originSessionId !== options.originSessionId) {
1023
- throw new Error("Continuation parent belongs to another Pi session");
1054
+ if (parent.status === "parked") {
1055
+ const parentLease = this.requireLease(parent.resourceId);
1056
+ if (parentLease.ownerId !== null) {
1057
+ throw new Error("Continuation parent still has an active owner");
1024
1058
  }
1025
- if (parent.status === "parked") {
1026
- const parentLease = this.requireLease(parent.resourceId);
1027
- if (parentLease.ownerId !== null) {
1028
- throw new Error("Continuation parent still has an active owner");
1029
- }
1030
- this.state.connection
1031
- .prepare(
1032
- `UPDATE run_queue
1059
+ this.state.connection
1060
+ .prepare(
1061
+ `UPDATE run_queue
1033
1062
  SET status = 'done', updated_at = ?, finished_at = ?
1034
1063
  WHERE run_id = ? AND status = 'parked'`,
1035
- )
1036
- .run(now, now, parent.runId);
1037
- const parentRevision = this.resourceRevision(parent.resourceId);
1038
- this.bumpResource(parent.resourceId, parentRevision, now);
1039
- this.insertEvent(
1040
- parent.resourceId,
1041
- parentRevision + 1,
1042
- "run.queue_done_for_continuation",
1043
- "session",
1044
- options.originSessionId,
1045
- { continuationRunId: options.runId },
1046
- now,
1047
- );
1048
- } else if (parent.status === "done") {
1049
- throw new Error("Continuation parent already has a reserved continuation");
1050
- } else {
1051
- throw new Error(`Continuation parent queue is ${parent.status}`);
1052
- }
1064
+ )
1065
+ .run(now, now, parent.runId);
1066
+ const parentRevision = this.resourceRevision(parent.resourceId);
1067
+ this.bumpResource(parent.resourceId, parentRevision, now);
1068
+ this.insertEvent(
1069
+ parent.resourceId,
1070
+ parentRevision + 1,
1071
+ "run.queue_done_for_continuation",
1072
+ "session",
1073
+ options.originSessionId,
1074
+ { continuationRunId: options.runId },
1075
+ now,
1076
+ );
1077
+ } else if (parent.status === "done") {
1078
+ throw new Error("Continuation parent already has a reserved continuation");
1079
+ } else {
1080
+ throw new Error(`Continuation parent queue is ${parent.status}`);
1053
1081
  }
1054
- const resourceId = resourceIdFor("run", options.runId);
1055
- const definitionHash = this.state.putJson(options.definitionSnapshot, now);
1056
- const queuedSource = queuedWorkflowSource(options.workflowSource);
1057
- const inputHash = this.state.putJson(options.input ?? null, now);
1058
- const launchHash = this.state.putJson(options.launchOptions ?? {}, now);
1059
- this.state.connection
1060
- .prepare(
1061
- `INSERT INTO workflow_definitions(
1082
+ }
1083
+ const resourceId = resourceIdFor("run", options.runId);
1084
+ const definitionHash = this.state.putJson(options.definitionSnapshot, now);
1085
+ const queuedSource = queuedWorkflowSource(options.workflowSource);
1086
+ const inputHash = this.state.putJson(options.input ?? null, now);
1087
+ const launchHash = this.state.putJson(options.launchOptions ?? {}, now);
1088
+ this.state.connection
1089
+ .prepare(
1090
+ `INSERT INTO workflow_definitions(
1062
1091
  definition_digest, workflow_name, definition_hash, created_at
1063
1092
  ) VALUES (?, ?, ?, ?)
1064
1093
  ON CONFLICT(definition_digest) DO NOTHING`,
1065
- )
1066
- .run(definitionDigest, options.workflowName, definitionHash, now);
1067
- this.state.connection
1068
- .prepare(
1069
- `INSERT INTO resources(resource_id, resource_type, aggregate_key, revision, created_at, updated_at)
1094
+ )
1095
+ .run(definitionDigest, options.workflowName, definitionHash, now);
1096
+ this.state.connection
1097
+ .prepare(
1098
+ `INSERT INTO resources(resource_id, resource_type, aggregate_key, revision, created_at, updated_at)
1070
1099
  VALUES (?, 'run', ?, 1, ?, ?)`,
1071
- )
1072
- .run(resourceId, options.runId, now, now);
1073
- this.state.connection
1074
- .prepare("INSERT INTO leases(resource_id, generation) VALUES (?, 0)")
1075
- .run(resourceId);
1076
- this.state.connection
1077
- .prepare(
1078
- `INSERT INTO runs(
1100
+ )
1101
+ .run(resourceId, options.runId, now, now);
1102
+ this.state.connection
1103
+ .prepare("INSERT INTO leases(resource_id, generation) VALUES (?, 0)")
1104
+ .run(resourceId);
1105
+ this.state.connection
1106
+ .prepare(
1107
+ `INSERT INTO runs(
1079
1108
  run_id, resource_id, project_id, parent_run_id, definition_digest,
1080
1109
  workflow_ref, launch_options_hash, status, paused,
1081
1110
  input_hash, created_at, updated_at
1082
1111
  ) VALUES (?, ?, ?, ?, ?, ?, ?, 'queued', 0, ?, ?, ?)`,
1083
- )
1084
- .run(
1085
- options.runId,
1086
- resourceId,
1087
- this.requireProjectId(),
1088
- options.parentRunId ?? null,
1089
- definitionDigest,
1090
- options.workflowSourceRef,
1091
- launchHash,
1092
- inputHash,
1093
- now,
1094
- now,
1095
- );
1096
- insertQueuedRunSources(this.state, options.runId, queuedSource);
1097
- this.state.connection
1098
- .prepare(
1099
- `INSERT INTO run_bindings(run_id, origin_session_id, execution_mode, created_at)
1112
+ )
1113
+ .run(
1114
+ options.runId,
1115
+ resourceId,
1116
+ this.requireProjectId(),
1117
+ options.parentRunId ?? null,
1118
+ definitionDigest,
1119
+ options.workflowSourceRef,
1120
+ launchHash,
1121
+ inputHash,
1122
+ now,
1123
+ now,
1124
+ );
1125
+ insertQueuedRunSources(this.state, options.runId, queuedSource);
1126
+ this.state.connection
1127
+ .prepare(
1128
+ `INSERT INTO run_bindings(run_id, origin_session_id, execution_mode, created_at)
1100
1129
  VALUES (?, ?, 'interactive', ?)`,
1101
- )
1102
- .run(options.runId, options.originSessionId, now);
1103
- this.state.connection
1104
- .prepare(
1105
- `INSERT INTO run_queue(
1130
+ )
1131
+ .run(options.runId, options.originSessionId, now);
1132
+ this.state.connection
1133
+ .prepare(
1134
+ `INSERT INTO run_queue(
1106
1135
  run_id, status, available_at, affinity_runner_id, origin_session_id,
1107
1136
  consecutive_errors, created_at, updated_at
1108
1137
  ) VALUES (?, 'queued', ?, ?, ?, 0, ?, ?)`,
1109
- )
1110
- .run(options.runId, now, options.runnerId, options.originSessionId, now, now);
1111
- this.insertEvent(resourceId, 1, "run.queued", "session", options.originSessionId, {}, now);
1112
- return this.requireWorkflowRun(options.runId);
1138
+ )
1139
+ .run(options.runId, now, options.runnerId, options.originSessionId, now, now);
1140
+ this.insertEvent(resourceId, 1, "run.queued", "session", options.originSessionId, {}, now);
1141
+ return this.requireWorkflowRun(options.runId);
1142
+ }
1143
+
1144
+ prepareOrAdoptWorkflowRun(options: WorkflowRunClaimOptions): WorkflowRunPreparationResult {
1145
+ validateRunId(options.runId);
1146
+ const now = epoch(validTimestamp(options.now));
1147
+ const definitionDigest = digestBuffer(options.definitionDigest);
1148
+ return this.state.transaction(() => {
1149
+ const existing = this.workflowRunRow(options.runId);
1150
+ if (existing !== undefined) {
1151
+ this.assertWorkflowRunPreparationCompatible(existing, options, definitionDigest);
1152
+ return { state: "adopted", run: this.mapWorkflowRun(existing) };
1153
+ }
1154
+ this.reserveWorkflowRunInTransaction(options, now, definitionDigest);
1155
+ const claimed = this.claimRunInTransaction(
1156
+ options.runId,
1157
+ options.runnerId,
1158
+ options.claimToken,
1159
+ options.leaseMs,
1160
+ now,
1161
+ );
1162
+ if (claimed === undefined) {
1163
+ throw new Error(`Workflow run could not be claimed: ${options.runId}`);
1164
+ }
1165
+ return {
1166
+ state: "claimed",
1167
+ run: claimed as WorkflowRunQueueRecord & { claimToken: string },
1168
+ };
1113
1169
  });
1114
1170
  }
1115
1171
 
1116
- enqueueWorkflowRun(options: {
1117
- runId: string;
1118
- workflowName: string;
1119
- workflowSourceRef: string;
1120
- workflowSource: unknown;
1121
- definitionDigest: string;
1122
- definitionSnapshot: unknown;
1123
- input: unknown;
1124
- launchOptions?: unknown;
1125
- runnerId: string;
1126
- claimToken: string;
1127
- leaseMs: number;
1128
- originSessionId: string;
1129
- parentRunId?: string;
1130
- now?: string;
1131
- }): WorkflowRunQueueRecord {
1172
+ enqueueWorkflowRun(options: WorkflowRunClaimOptions): WorkflowRunQueueRecord {
1132
1173
  if (this.getWorkflowRun(options.runId) === undefined) {
1133
1174
  this.reserveWorkflowRun({
1134
1175
  runId: options.runId,
@@ -2228,6 +2269,27 @@ export class SqliteControllerStore implements ControllerStore {
2228
2269
  return this.mapWorkflowRun(this.requireWorkflowRunRow(runId));
2229
2270
  }
2230
2271
 
2272
+ private assertWorkflowRunPreparationCompatible(
2273
+ row: RunRow,
2274
+ options: WorkflowRunReservationOptions,
2275
+ definitionDigest: Buffer,
2276
+ ): void {
2277
+ const compatible =
2278
+ row.workflowName === options.workflowName &&
2279
+ row.workflowRef === options.workflowSourceRef &&
2280
+ row.definitionDigest.equals(definitionDigest) &&
2281
+ canonicalJson(readQueuedRunSources(this.state, row)) ===
2282
+ canonicalJson(queuedWorkflowSource(options.workflowSource)) &&
2283
+ canonicalJson(this.state.readJson(row.inputHash)) === canonicalJson(options.input ?? null) &&
2284
+ canonicalJson(this.state.readJson(row.launchOptionsHash)) ===
2285
+ canonicalJson(options.launchOptions ?? {}) &&
2286
+ row.originSessionId === options.originSessionId &&
2287
+ row.parentRunId === (options.parentRunId ?? null);
2288
+ if (!compatible) {
2289
+ throw new Error(`Workflow run preparation conflicts: ${options.runId}`);
2290
+ }
2291
+ }
2292
+
2231
2293
  /* istanbul ignore next -- pure projection covered by integration tests */
2232
2294
  private mapWorkflowRun(row: RunRow): WorkflowRunQueueRecord {
2233
2295
  return {
@@ -2267,56 +2329,65 @@ export class SqliteControllerStore implements ControllerStore {
2267
2329
  leaseMs: number,
2268
2330
  now: number,
2269
2331
  ): WorkflowRunQueueRecord | undefined {
2270
- return this.state.transaction(() => {
2271
- const row = this.workflowRunRow(runId);
2272
- if (row === undefined || ["done", "failed", "cancelled"].includes(row.status))
2273
- return undefined;
2274
- const lease = this.requireLease(row.resourceId);
2275
- if (
2276
- lease.ownerId !== null &&
2277
- lease.expiresAt !== null &&
2278
- lease.expiresAt > now &&
2279
- lease.ownerId !== runnerId
2280
- )
2281
- return undefined;
2282
- const generation = lease.generation + 1;
2283
- const expiresAt = now + leaseMs;
2284
- const result = this.state.connection
2285
- .prepare(
2286
- `UPDATE leases SET generation = ?, owner_type = ?, owner_id = ?, token_hash = ?,
2332
+ return this.state.transaction(() =>
2333
+ this.claimRunInTransaction(runId, runnerId, claimToken, leaseMs, now),
2334
+ );
2335
+ }
2336
+
2337
+ private claimRunInTransaction(
2338
+ runId: string,
2339
+ runnerId: string,
2340
+ claimToken: string,
2341
+ leaseMs: number,
2342
+ now: number,
2343
+ ): WorkflowRunQueueRecord | undefined {
2344
+ const row = this.workflowRunRow(runId);
2345
+ if (row === undefined || ["done", "failed", "cancelled"].includes(row.status)) return undefined;
2346
+ const lease = this.requireLease(row.resourceId);
2347
+ if (
2348
+ lease.ownerId !== null &&
2349
+ lease.expiresAt !== null &&
2350
+ lease.expiresAt > now &&
2351
+ lease.ownerId !== runnerId
2352
+ )
2353
+ return undefined;
2354
+ const generation = lease.generation + 1;
2355
+ const expiresAt = now + leaseMs;
2356
+ const result = this.state.connection
2357
+ .prepare(
2358
+ `UPDATE leases SET generation = ?, owner_type = ?, owner_id = ?, token_hash = ?,
2287
2359
  acquired_at = ?, heartbeat_at = ?, expires_at = ?
2288
2360
  WHERE resource_id = ? AND generation = ?`,
2289
- )
2290
- .run(
2291
- generation,
2292
- runnerId.startsWith("host-") ? "host" : "session",
2293
- runnerId,
2294
- tokenHash(claimToken),
2295
- now,
2296
- now,
2297
- expiresAt,
2298
- row.resourceId,
2299
- lease.generation,
2300
- );
2301
- /* istanbul ignore if -- impossible after exact schema and transaction checks */
2302
- if (result.changes !== 1) return undefined;
2303
- this.state.connection
2304
- .prepare("UPDATE run_queue SET status = 'starting', updated_at = ? WHERE run_id = ?")
2305
- .run(now, runId);
2306
- const revision = this.resourceRevision(row.resourceId);
2307
- this.bumpResource(row.resourceId, revision, now);
2308
- this.insertEvent(
2309
- row.resourceId,
2310
- revision + 1,
2311
- "lease.claimed",
2361
+ )
2362
+ .run(
2363
+ generation,
2312
2364
  runnerId.startsWith("host-") ? "host" : "session",
2313
2365
  runnerId,
2314
- { expiresAt },
2366
+ tokenHash(claimToken),
2315
2367
  now,
2316
- generation,
2368
+ now,
2369
+ expiresAt,
2370
+ row.resourceId,
2371
+ lease.generation,
2317
2372
  );
2318
- return { ...this.requireWorkflowRun(runId), claimToken };
2319
- });
2373
+ /* istanbul ignore if -- impossible after exact schema and transaction checks */
2374
+ if (result.changes !== 1) return undefined;
2375
+ this.state.connection
2376
+ .prepare("UPDATE run_queue SET status = 'starting', updated_at = ? WHERE run_id = ?")
2377
+ .run(now, runId);
2378
+ const revision = this.resourceRevision(row.resourceId);
2379
+ this.bumpResource(row.resourceId, revision, now);
2380
+ this.insertEvent(
2381
+ row.resourceId,
2382
+ revision + 1,
2383
+ "lease.claimed",
2384
+ runnerId.startsWith("host-") ? "host" : "session",
2385
+ runnerId,
2386
+ { expiresAt },
2387
+ now,
2388
+ generation,
2389
+ );
2390
+ return { ...this.requireWorkflowRun(runId), claimToken };
2320
2391
  }
2321
2392
 
2322
2393
  private updateClaimedRunStatus(