@osolmaz/pi-workflows 0.11.0 → 0.11.2

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 (36) hide show
  1. package/README.md +14 -2
  2. package/dist/builtins/autoimplement.workflow.d.ts +2 -0
  3. package/dist/builtins/autoimplement.workflow.js +186 -9
  4. package/dist/builtins/autoimplement.workflow.js.map +1 -1
  5. package/dist/controllers/sqlite.d.ts +55 -7
  6. package/dist/controllers/sqlite.js +195 -48
  7. package/dist/controllers/sqlite.js.map +1 -1
  8. package/dist/extension/index.js +246 -54
  9. package/dist/extension/index.js.map +1 -1
  10. package/dist/herdr/setup.d.ts +13 -1
  11. package/dist/herdr/setup.js +349 -36
  12. package/dist/herdr/setup.js.map +1 -1
  13. package/dist/host/runner.js +3 -0
  14. package/dist/host/runner.js.map +1 -1
  15. package/dist/viewer/cli.d.ts +1 -0
  16. package/dist/viewer/cli.js +21 -10
  17. package/dist/viewer/cli.js.map +1 -1
  18. package/dist/workflows/migrate-sources.d.ts +1 -1
  19. package/dist/workflows/migrate-sources.js.map +1 -1
  20. package/dist/workflows/tool-input.d.ts +1 -0
  21. package/dist/workflows/tool-input.js +2 -2
  22. package/dist/workflows/tool-input.js.map +1 -1
  23. package/docs/2026-08-20-durable-workflow-launch-plan.md +449 -0
  24. package/docs/plans/2026-08-20-autoimplement-blocker-challenge-plan.md +138 -0
  25. package/docs/plans/2026-08-20-herdr-plugin-sync-plan.md +104 -0
  26. package/docs/workflows.md +11 -0
  27. package/herdr-plugin.toml +1 -1
  28. package/package.json +1 -1
  29. package/src/builtins/autoimplement.workflow.ts +212 -9
  30. package/src/controllers/sqlite.ts +308 -54
  31. package/src/extension/index.ts +303 -58
  32. package/src/herdr/setup.ts +429 -39
  33. package/src/host/runner.ts +3 -0
  34. package/src/viewer/cli.ts +22 -10
  35. package/src/workflows/migrate-sources.ts +5 -1
  36. package/src/workflows/tool-input.ts +5 -2
@@ -83,20 +83,36 @@ type WorkflowRow = {
83
83
  error: string | null;
84
84
  };
85
85
 
86
+ export type WorkflowRunLaunchStatus =
87
+ | "queued"
88
+ | "starting"
89
+ | "running"
90
+ | "parked"
91
+ | "done"
92
+ | "failed"
93
+ | "cancelled";
94
+
86
95
  type WorkflowRunQueueRow = {
87
96
  run_id: string;
88
97
  workflow_ref: string;
89
98
  workflow_path: string;
99
+ workflow_source_json: string;
100
+ definition_digest: string;
90
101
  input_json: string;
91
- status: "claimed" | "parked" | "done";
102
+ launch_options_json: string;
103
+ status: WorkflowRunLaunchStatus;
92
104
  runner_id: string | null;
93
105
  claim_token: string | null;
94
106
  claim_expires_at: number | null;
95
107
  affinity_runner_id: string | null;
96
108
  origin_session_id: string | null;
97
109
  parent_run_id: string | null;
110
+ error_code: string | null;
111
+ error_message: string | null;
98
112
  created_at: string;
99
113
  updated_at: string;
114
+ started_at: string | null;
115
+ finished_at: string | null;
100
116
  };
101
117
 
102
118
  /** A user-started workflow run tracked by the durable run queue. */
@@ -106,8 +122,11 @@ export type WorkflowRunQueueRecord = {
106
122
  workflowName: string;
107
123
  /** Canonical source reference used to reopen the run. */
108
124
  workflowSourceRef: string;
125
+ workflowSource: unknown;
126
+ definitionDigest: string;
109
127
  input: unknown;
110
- status: "claimed" | "parked" | "done";
128
+ launchOptions: unknown;
129
+ status: WorkflowRunLaunchStatus;
111
130
  runnerId: string | null;
112
131
  claimToken: string | null;
113
132
  claimExpiresAt: string | null;
@@ -115,8 +134,12 @@ export type WorkflowRunQueueRecord = {
115
134
  /** Pi session that owns delivery and interactive execution, or null for detached runs. */
116
135
  originSessionId: string | null;
117
136
  parentRunId: string | null;
137
+ errorCode: string | null;
138
+ errorMessage: string | null;
118
139
  createdAt: string;
119
140
  updatedAt: string;
141
+ startedAt: string | null;
142
+ finishedAt: string | null;
120
143
  };
121
144
 
122
145
  type WorkflowNotificationRow = {
@@ -126,7 +149,7 @@ type WorkflowNotificationRow = {
126
149
  attempt_id: string;
127
150
  notification_index: number;
128
151
  target_session_id: string;
129
- kind: "progress" | "final";
152
+ kind: "progress" | "final" | "launch_failure";
130
153
  content: string;
131
154
  created_at: string;
132
155
  delivery_claim_token: string | null;
@@ -141,7 +164,7 @@ export type WorkflowNotificationRecord = {
141
164
  attemptId: string;
142
165
  notificationIndex: number;
143
166
  targetSessionId: string;
144
- kind: "progress" | "final";
167
+ kind: "progress" | "final" | "launch_failure";
145
168
  content: string;
146
169
  createdAt: string;
147
170
  deliveryClaimExpiresAt: string | null;
@@ -799,21 +822,57 @@ export class SqliteControllerStore implements ControllerStore {
799
822
  if (row === undefined) {
800
823
  throw new Error("Controller store has no schema identifier");
801
824
  }
802
- return;
825
+ } else {
826
+ const existingQueue = this.database
827
+ .prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'workflow_run_queue'")
828
+ .get();
829
+ if (existingQueue !== undefined) this.assertAlphaSchemaLayout();
830
+ this.transaction(() => {
831
+ this.database
832
+ .prepare("INSERT OR IGNORE INTO schema_info (singleton, schema_id) VALUES (1, ?)")
833
+ .run(CONTROLLER_STORE_SCHEMA);
834
+ this.database.exec(SCHEMA_SQL);
835
+ });
836
+ }
837
+ this.assertAlphaSchemaLayout();
838
+ }
839
+
840
+ private assertAlphaSchemaLayout(): void {
841
+ const requiredQueueColumns = new Set([
842
+ "run_id",
843
+ "workflow_ref",
844
+ "workflow_path",
845
+ "workflow_source_json",
846
+ "definition_digest",
847
+ "input_json",
848
+ "launch_options_json",
849
+ "status",
850
+ "runner_id",
851
+ "claim_token",
852
+ "claim_expires_at",
853
+ "affinity_runner_id",
854
+ "origin_session_id",
855
+ "parent_run_id",
856
+ "error_code",
857
+ "error_message",
858
+ "created_at",
859
+ "updated_at",
860
+ "started_at",
861
+ "finished_at",
862
+ ]);
863
+ const actual = new Set(
864
+ (
865
+ this.database.pragma("table_info(workflow_run_queue)") as {
866
+ name: string;
867
+ }[]
868
+ ).map((column) => column.name),
869
+ );
870
+ const missing = [...requiredQueueColumns].filter((column) => !actual.has(column));
871
+ if (missing.length > 0) {
872
+ throw new Error(
873
+ "Controller store uses an incompatible alpha layout. Preserve needed run bundles, then reset the project controller store.",
874
+ );
803
875
  }
804
- this.transaction(() => {
805
- this.database
806
- .prepare("INSERT OR IGNORE INTO schema_info (singleton, schema_id) VALUES (1, ?)")
807
- .run(CONTROLLER_STORE_SCHEMA);
808
- this.database.exec(SCHEMA_SQL);
809
- const queueColumns = this.database.pragma("table_info(workflow_run_queue)") as {
810
- name: string;
811
- }[];
812
- if (!queueColumns.some((column) => column.name === "origin_session_id")) {
813
- this.database.exec("ALTER TABLE workflow_run_queue ADD COLUMN origin_session_id TEXT");
814
- }
815
- this.database.exec("DROP TABLE IF EXISTS session_watermarks");
816
- });
817
876
  }
818
877
 
819
878
  private transaction<T>(task: () => T): T {
@@ -901,15 +960,68 @@ export class SqliteControllerStore implements ControllerStore {
901
960
  return workflow;
902
961
  }
903
962
 
904
- /**
905
- * Insert a user-started run and claim it in one statement, so the
906
- * originating runner owns the run from birth (origin affinity).
907
- */
963
+ /** Reserve a user-started run before the initiating agent turn settles. */
964
+ reserveWorkflowRun(options: {
965
+ runId: string;
966
+ workflowName: string;
967
+ workflowSourceRef: string;
968
+ workflowSource: unknown;
969
+ definitionDigest: string;
970
+ input: unknown;
971
+ launchOptions?: unknown;
972
+ runnerId: string;
973
+ originSessionId: string;
974
+ parentRunId?: string;
975
+ now?: string;
976
+ }): WorkflowRunQueueRecord {
977
+ validateRunId(options.runId);
978
+ validateKey(options.workflowName, "workflow name");
979
+ validateKey(options.workflowSourceRef, "workflow source ref");
980
+ validateKey(options.definitionDigest, "workflow definition digest");
981
+ validateKey(options.runnerId, "runner id");
982
+ validateKey(options.originSessionId, "origin session id");
983
+ const inputJson = canonicalJson(options.input ?? null, "workflow run input");
984
+ const sourceJson = canonicalJson(options.workflowSource, "workflow run source");
985
+ const launchJson = canonicalJson(options.launchOptions ?? {}, "workflow launch options");
986
+ validateJsonSize(inputJson, "Workflow run input", MAX_RESOURCE_VALUE_BYTES);
987
+ validateJsonSize(sourceJson, "Workflow run source", MAX_EVENT_BYTES);
988
+ validateJsonSize(launchJson, "Workflow launch options", MAX_EVENT_BYTES);
989
+ const now = validTimestamp(options.now);
990
+ this.database
991
+ .prepare(
992
+ `INSERT INTO workflow_run_queue (
993
+ run_id, workflow_ref, workflow_path, workflow_source_json, definition_digest,
994
+ input_json, launch_options_json, status, runner_id, claim_token, claim_expires_at,
995
+ affinity_runner_id, origin_session_id, parent_run_id, error_code, error_message,
996
+ created_at, updated_at, started_at, finished_at
997
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, 'queued', NULL, NULL, NULL, ?, ?, ?, NULL, NULL, ?, ?, NULL, NULL)`,
998
+ )
999
+ .run(
1000
+ options.runId,
1001
+ options.workflowName,
1002
+ options.workflowSourceRef,
1003
+ sourceJson,
1004
+ options.definitionDigest,
1005
+ inputJson,
1006
+ launchJson,
1007
+ options.runnerId,
1008
+ options.originSessionId,
1009
+ options.parentRunId ?? null,
1010
+ now,
1011
+ now,
1012
+ );
1013
+ return this.requireWorkflowRun(options.runId);
1014
+ }
1015
+
1016
+ /** Insert a run that will start immediately outside a live model turn. */
908
1017
  enqueueWorkflowRun(options: {
909
1018
  runId: string;
910
1019
  workflowName: string;
911
1020
  workflowSourceRef: string;
1021
+ workflowSource?: unknown;
1022
+ definitionDigest?: string;
912
1023
  input: unknown;
1024
+ launchOptions?: unknown;
913
1025
  runnerId: string;
914
1026
  claimToken: string;
915
1027
  leaseMs: number;
@@ -928,22 +1040,31 @@ export class SqliteControllerStore implements ControllerStore {
928
1040
  validateKey(options.originSessionId, "origin session id");
929
1041
  }
930
1042
  const inputJson = canonicalJson(options.input ?? null, "workflow run input");
1043
+ const sourceJson = canonicalJson(
1044
+ options.workflowSource ?? { ref: options.workflowSourceRef },
1045
+ "workflow run source",
1046
+ );
1047
+ const launchJson = canonicalJson(options.launchOptions ?? {}, "workflow launch options");
931
1048
  validateJsonSize(inputJson, "Workflow run input", MAX_RESOURCE_VALUE_BYTES);
932
1049
  const now = validTimestamp(options.now);
933
1050
  const expiresAt = epoch(now) + options.leaseMs;
934
1051
  this.database
935
1052
  .prepare(
936
1053
  `INSERT INTO workflow_run_queue (
937
- run_id, workflow_ref, workflow_path, input_json, status,
938
- runner_id, claim_token, claim_expires_at, affinity_runner_id,
939
- origin_session_id, parent_run_id, created_at, updated_at
940
- ) VALUES (?, ?, ?, ?, 'claimed', ?, ?, ?, ?, ?, ?, ?, ?)`,
1054
+ run_id, workflow_ref, workflow_path, workflow_source_json, definition_digest,
1055
+ input_json, launch_options_json, status, runner_id, claim_token, claim_expires_at,
1056
+ affinity_runner_id, origin_session_id, parent_run_id, error_code, error_message,
1057
+ created_at, updated_at, started_at, finished_at
1058
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, 'starting', ?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?, ?, NULL)`,
941
1059
  )
942
1060
  .run(
943
1061
  options.runId,
944
1062
  options.workflowName,
945
1063
  options.workflowSourceRef,
1064
+ sourceJson,
1065
+ options.definitionDigest ?? "unavailable",
946
1066
  inputJson,
1067
+ launchJson,
947
1068
  options.runnerId,
948
1069
  options.claimToken,
949
1070
  expiresAt,
@@ -952,6 +1073,7 @@ export class SqliteControllerStore implements ControllerStore {
952
1073
  options.parentRunId ?? null,
953
1074
  now,
954
1075
  now,
1076
+ now,
955
1077
  );
956
1078
  return this.requireWorkflowRun(options.runId);
957
1079
  }
@@ -978,6 +1100,58 @@ export class SqliteControllerStore implements ControllerStore {
978
1100
  return rows.map(workflowRunFromRow);
979
1101
  }
980
1102
 
1103
+ findSessionReservation(sessionId: string): WorkflowRunQueueRecord | undefined {
1104
+ validateKey(sessionId, "session id");
1105
+ const row = this.database
1106
+ .prepare(
1107
+ `SELECT * FROM workflow_run_queue
1108
+ WHERE origin_session_id = ? AND status IN ('queued', 'starting', 'running')
1109
+ ORDER BY created_at LIMIT 1`,
1110
+ )
1111
+ .get(sessionId) as WorkflowRunQueueRow | undefined;
1112
+ return row === undefined ? undefined : workflowRunFromRow(row);
1113
+ }
1114
+
1115
+ claimWorkflowRun(options: {
1116
+ runId: string;
1117
+ runnerId: string;
1118
+ claimToken: string;
1119
+ leaseMs: number;
1120
+ now?: string;
1121
+ }): WorkflowRunQueueRecord | undefined {
1122
+ validateRunId(options.runId);
1123
+ validateKey(options.runnerId, "runner id");
1124
+ validateKey(options.claimToken, "claim token");
1125
+ validateDuration(options.leaseMs, "leaseMs");
1126
+ const now = validTimestamp(options.now);
1127
+ const nowMs = epoch(now);
1128
+ const expiresAt = nowMs + options.leaseMs;
1129
+ const result = this.database
1130
+ .prepare(
1131
+ `UPDATE workflow_run_queue
1132
+ SET status = 'starting', runner_id = ?, claim_token = ?, claim_expires_at = ?,
1133
+ started_at = COALESCE(started_at, ?), updated_at = ?
1134
+ WHERE run_id = ? AND (
1135
+ status IN ('queued', 'parked')
1136
+ OR (status = 'starting' AND claim_expires_at IS NOT NULL AND claim_expires_at <= ?)
1137
+ )`,
1138
+ )
1139
+ .run(options.runnerId, options.claimToken, expiresAt, now, now, options.runId, nowMs);
1140
+ return result.changes === 1 ? this.requireWorkflowRun(options.runId) : undefined;
1141
+ }
1142
+
1143
+ markWorkflowRunRunning(options: { runId: string; claimToken: string; now?: string }): boolean {
1144
+ validateRunId(options.runId);
1145
+ const now = validTimestamp(options.now);
1146
+ const result = this.database
1147
+ .prepare(
1148
+ `UPDATE workflow_run_queue SET status = 'running', updated_at = ?
1149
+ WHERE run_id = ? AND claim_token = ? AND status = 'starting'`,
1150
+ )
1151
+ .run(now, options.runId, options.claimToken);
1152
+ return result.changes === 1;
1153
+ }
1154
+
981
1155
  /**
982
1156
  * Claim the oldest claimable run, preferring runs with affinity to this
983
1157
  * runner. Parked rows are claimable immediately; claimed rows become
@@ -1008,11 +1182,11 @@ export class SqliteControllerStore implements ControllerStore {
1008
1182
  excluded.length === 0 ? "" : `AND run_id NOT IN (${excluded.map(() => "?").join(", ")})`;
1009
1183
  const sessionFilter =
1010
1184
  options.sessionId === undefined
1011
- ? ""
1185
+ ? "AND status != 'queued' AND (status != 'starting' OR origin_session_id IS NULL)"
1012
1186
  : "AND (origin_session_id IS NULL OR origin_session_id = ?)";
1013
1187
  const claimable = `(
1014
- status = 'parked'
1015
- OR (status = 'claimed' AND claim_expires_at IS NOT NULL AND claim_expires_at <= ?)
1188
+ status IN ('queued', 'parked')
1189
+ OR (status IN ('starting', 'running') AND claim_expires_at IS NOT NULL AND claim_expires_at <= ?)
1016
1190
  )`;
1017
1191
  return this.transaction(() => {
1018
1192
  const candidate = this.database
@@ -1036,14 +1210,14 @@ export class SqliteControllerStore implements ControllerStore {
1036
1210
  const result = this.database
1037
1211
  .prepare(
1038
1212
  `UPDATE workflow_run_queue
1039
- SET status = 'claimed', runner_id = ?, claim_token = ?,
1040
- claim_expires_at = ?, updated_at = ?
1213
+ SET status = 'starting', runner_id = ?, claim_token = ?,
1214
+ claim_expires_at = ?, started_at = COALESCE(started_at, ?), updated_at = ?
1041
1215
  WHERE run_id = ? AND (
1042
- status = 'parked'
1043
- OR (status = 'claimed' AND claim_expires_at IS NOT NULL AND claim_expires_at <= ?)
1216
+ status IN ('queued', 'parked')
1217
+ OR (status IN ('starting', 'running') AND claim_expires_at IS NOT NULL AND claim_expires_at <= ?)
1044
1218
  )`,
1045
1219
  )
1046
- .run(options.runnerId, options.claimToken, expiresAt, now, candidate.run_id, nowMs);
1220
+ .run(options.runnerId, options.claimToken, expiresAt, now, now, candidate.run_id, nowMs);
1047
1221
  return result.changes === 1 ? this.requireWorkflowRun(candidate.run_id) : undefined;
1048
1222
  });
1049
1223
  }
@@ -1062,7 +1236,7 @@ export class SqliteControllerStore implements ControllerStore {
1062
1236
  const result = this.database
1063
1237
  .prepare(
1064
1238
  `UPDATE workflow_run_queue SET claim_expires_at = ?
1065
- WHERE run_id = ? AND claim_token = ? AND status = 'claimed'
1239
+ WHERE run_id = ? AND claim_token = ? AND status IN ('starting', 'running')
1066
1240
  AND claim_expires_at > ?`,
1067
1241
  )
1068
1242
  .run(expiresAt, options.runId, options.claimToken, nowMs);
@@ -1076,7 +1250,7 @@ export class SqliteControllerStore implements ControllerStore {
1076
1250
  const row = this.database
1077
1251
  .prepare(
1078
1252
  `SELECT 1 AS live FROM workflow_run_queue
1079
- WHERE run_id = ? AND claim_token = ? AND status = 'claimed'
1253
+ WHERE run_id = ? AND claim_token = ? AND status IN ('starting', 'running')
1080
1254
  AND claim_expires_at > ?`,
1081
1255
  )
1082
1256
  .get(options.runId, options.claimToken, nowMs);
@@ -1092,12 +1266,68 @@ export class SqliteControllerStore implements ControllerStore {
1092
1266
  `UPDATE workflow_run_queue
1093
1267
  SET status = 'parked', runner_id = NULL, claim_token = NULL,
1094
1268
  claim_expires_at = NULL, updated_at = ?
1095
- WHERE run_id = ? AND claim_token = ? AND status = 'claimed'`,
1269
+ WHERE run_id = ? AND claim_token = ? AND status IN ('starting', 'running')`,
1096
1270
  )
1097
1271
  .run(now, options.runId, options.claimToken);
1098
1272
  return result.changes === 1;
1099
1273
  }
1100
1274
 
1275
+ failWorkflowRun(options: {
1276
+ runId: string;
1277
+ claimToken?: string;
1278
+ errorCode: string;
1279
+ errorMessage: string;
1280
+ now?: string;
1281
+ }): boolean {
1282
+ validateRunId(options.runId);
1283
+ validateKey(options.errorCode, "workflow launch error code");
1284
+ const message = options.errorMessage.trim();
1285
+ if (message.length === 0 || Buffer.byteLength(message) > 2_048) {
1286
+ throw new Error("Workflow launch error message must be between 1 and 2048 bytes");
1287
+ }
1288
+ const now = validTimestamp(options.now);
1289
+ const claimFilter = options.claimToken === undefined ? "" : "AND claim_token = ?";
1290
+ const result = this.database
1291
+ .prepare(
1292
+ `UPDATE workflow_run_queue
1293
+ SET status = 'failed', runner_id = NULL, claim_token = NULL,
1294
+ claim_expires_at = NULL, parent_run_id = NULL, input_json = 'null',
1295
+ launch_options_json = '{}', error_code = ?, error_message = ?,
1296
+ finished_at = ?, updated_at = ?
1297
+ WHERE run_id = ? AND status IN ('queued', 'starting', 'running') ${claimFilter}`,
1298
+ )
1299
+ .run(
1300
+ options.errorCode,
1301
+ message,
1302
+ now,
1303
+ now,
1304
+ options.runId,
1305
+ ...(options.claimToken === undefined ? [] : [options.claimToken]),
1306
+ );
1307
+ return result.changes === 1;
1308
+ }
1309
+
1310
+ cancelWorkflowRun(options: { runId: string; claimToken?: string; now?: string }): boolean {
1311
+ validateRunId(options.runId);
1312
+ const now = validTimestamp(options.now);
1313
+ const claimFilter = options.claimToken === undefined ? "" : "AND claim_token = ?";
1314
+ const result = this.database
1315
+ .prepare(
1316
+ `UPDATE workflow_run_queue
1317
+ SET status = 'cancelled', runner_id = NULL, claim_token = NULL,
1318
+ claim_expires_at = NULL, input_json = 'null', launch_options_json = '{}',
1319
+ finished_at = ?, updated_at = ?
1320
+ WHERE run_id = ? AND status IN ('queued', 'starting') ${claimFilter}`,
1321
+ )
1322
+ .run(
1323
+ now,
1324
+ now,
1325
+ options.runId,
1326
+ ...(options.claimToken === undefined ? [] : [options.claimToken]),
1327
+ );
1328
+ return result.changes === 1;
1329
+ }
1330
+
1101
1331
  /**
1102
1332
  * Delete a claimed row. Used when a continuation fails before its bundle
1103
1333
  * exists, so the parent's one-continuation slot is not consumed by a run
@@ -1119,10 +1349,11 @@ export class SqliteControllerStore implements ControllerStore {
1119
1349
  .prepare(
1120
1350
  `UPDATE workflow_run_queue
1121
1351
  SET status = 'done', runner_id = NULL, claim_token = NULL,
1122
- claim_expires_at = NULL, updated_at = ?
1123
- WHERE run_id = ? AND claim_token = ? AND status = 'claimed'`,
1352
+ claim_expires_at = NULL, input_json = 'null', launch_options_json = '{}',
1353
+ finished_at = ?, updated_at = ?
1354
+ WHERE run_id = ? AND claim_token = ? AND status IN ('starting', 'running')`,
1124
1355
  )
1125
- .run(now, options.runId, options.claimToken);
1356
+ .run(now, now, options.runId, options.claimToken);
1126
1357
  return result.changes === 1;
1127
1358
  }
1128
1359
 
@@ -1149,26 +1380,28 @@ export class SqliteControllerStore implements ControllerStore {
1149
1380
  const row = this.database
1150
1381
  .prepare("SELECT * FROM workflow_run_queue WHERE run_id = ?")
1151
1382
  .get(options.runId) as WorkflowRunQueueRow | undefined;
1152
- if (row === undefined || row.status === "done") return false;
1383
+ if (row === undefined || ["done", "failed", "cancelled"].includes(row.status)) {
1384
+ return false;
1385
+ }
1153
1386
  if (row.workflow_path === options.workflowSourceRef && row.status === "parked") {
1154
1387
  return "unchanged";
1155
1388
  }
1156
1389
  const claimable =
1157
1390
  row.status === "parked" ||
1158
- (row.status === "claimed" &&
1391
+ (row.status === "starting" &&
1159
1392
  row.claim_token === options.claimToken &&
1160
1393
  row.claim_expires_at !== null &&
1161
1394
  row.claim_expires_at > nowMs) ||
1162
- (row.status === "claimed" &&
1395
+ (row.status === "starting" &&
1163
1396
  row.claim_expires_at !== null &&
1164
1397
  row.claim_expires_at <= nowMs);
1165
1398
  if (!claimable) return false;
1166
1399
  const result = this.database
1167
1400
  .prepare(
1168
1401
  `UPDATE workflow_run_queue
1169
- SET workflow_ref = ?, workflow_path = ?, status = 'claimed', runner_id = ?,
1402
+ SET workflow_ref = ?, workflow_path = ?, status = 'starting', runner_id = ?,
1170
1403
  claim_token = ?, claim_expires_at = ?, updated_at = ?
1171
- WHERE run_id = ? AND status != 'done'`,
1404
+ WHERE run_id = ? AND status NOT IN ('done', 'failed', 'cancelled')`,
1172
1405
  )
1173
1406
  .run(
1174
1407
  options.workflowName,
@@ -1208,26 +1441,28 @@ export class SqliteControllerStore implements ControllerStore {
1208
1441
  const row = this.database
1209
1442
  .prepare("SELECT * FROM workflow_run_queue WHERE run_id = ?")
1210
1443
  .get(options.runId) as WorkflowRunQueueRow | undefined;
1211
- if (row === undefined || row.status === "done") return false;
1444
+ if (row === undefined || ["done", "failed", "cancelled"].includes(row.status)) {
1445
+ return false;
1446
+ }
1212
1447
  const sourceMatches =
1213
1448
  row.workflow_path === options.oldWorkflowPath ||
1214
1449
  row.workflow_path === options.workflowSourceRef;
1215
1450
  const claimable =
1216
1451
  row.status === "parked" ||
1217
- (row.status === "claimed" &&
1452
+ (row.status === "starting" &&
1218
1453
  row.claim_token === options.claimToken &&
1219
1454
  row.claim_expires_at !== null &&
1220
1455
  row.claim_expires_at > nowMs) ||
1221
- (row.status === "claimed" &&
1456
+ (row.status === "starting" &&
1222
1457
  row.claim_expires_at !== null &&
1223
1458
  row.claim_expires_at <= nowMs);
1224
1459
  if (!sourceMatches || !claimable) return false;
1225
1460
  const result = this.database
1226
1461
  .prepare(
1227
1462
  `UPDATE workflow_run_queue
1228
- SET workflow_ref = ?, workflow_path = ?, status = 'claimed', runner_id = ?,
1463
+ SET workflow_ref = ?, workflow_path = ?, status = 'starting', runner_id = ?,
1229
1464
  claim_token = ?, claim_expires_at = ?, updated_at = ?
1230
- WHERE run_id = ? AND status != 'done'`,
1465
+ WHERE run_id = ? AND status NOT IN ('done', 'failed', 'cancelled')`,
1231
1466
  )
1232
1467
  .run(
1233
1468
  options.workflowName,
@@ -1297,7 +1532,7 @@ export class SqliteControllerStore implements ControllerStore {
1297
1532
  attemptId: string;
1298
1533
  notificationIndex: number;
1299
1534
  targetSessionId: string;
1300
- kind: "progress" | "final";
1535
+ kind: "progress" | "final" | "launch_failure";
1301
1536
  content: string;
1302
1537
  notificationId?: string;
1303
1538
  now?: string;
@@ -1483,19 +1718,31 @@ const SCHEMA_SQL = `
1483
1718
  run_id TEXT PRIMARY KEY,
1484
1719
  workflow_ref TEXT NOT NULL,
1485
1720
  workflow_path TEXT NOT NULL,
1721
+ workflow_source_json TEXT NOT NULL,
1722
+ definition_digest TEXT NOT NULL,
1486
1723
  input_json TEXT NOT NULL,
1487
- status TEXT NOT NULL CHECK (status IN ('claimed', 'parked', 'done')),
1724
+ launch_options_json TEXT NOT NULL,
1725
+ status TEXT NOT NULL CHECK (
1726
+ status IN ('queued', 'starting', 'running', 'parked', 'done', 'failed', 'cancelled')
1727
+ ),
1488
1728
  runner_id TEXT,
1489
1729
  claim_token TEXT,
1490
1730
  claim_expires_at INTEGER,
1491
1731
  affinity_runner_id TEXT,
1492
1732
  origin_session_id TEXT,
1493
1733
  parent_run_id TEXT,
1734
+ error_code TEXT,
1735
+ error_message TEXT,
1494
1736
  created_at TEXT NOT NULL,
1495
- updated_at TEXT NOT NULL
1737
+ updated_at TEXT NOT NULL,
1738
+ started_at TEXT,
1739
+ finished_at TEXT
1496
1740
  );
1497
1741
  CREATE INDEX IF NOT EXISTS workflow_run_queue_claimable
1498
1742
  ON workflow_run_queue(status, claim_expires_at);
1743
+ CREATE UNIQUE INDEX IF NOT EXISTS workflow_run_queue_session_reservation
1744
+ ON workflow_run_queue(origin_session_id)
1745
+ WHERE origin_session_id IS NOT NULL AND status IN ('queued', 'starting', 'running');
1499
1746
  -- A checkpointed parent admits exactly one continuation run, across
1500
1747
  -- sessions and processes.
1501
1748
  CREATE UNIQUE INDEX IF NOT EXISTS workflow_run_queue_parent
@@ -1519,7 +1766,7 @@ const SCHEMA_SQL = `
1519
1766
  attempt_id TEXT NOT NULL,
1520
1767
  notification_index INTEGER NOT NULL CHECK (notification_index > 0),
1521
1768
  target_session_id TEXT NOT NULL,
1522
- kind TEXT NOT NULL CHECK (kind IN ('progress', 'final')),
1769
+ kind TEXT NOT NULL CHECK (kind IN ('progress', 'final', 'launch_failure')),
1523
1770
  content TEXT NOT NULL,
1524
1771
  created_at TEXT NOT NULL,
1525
1772
  delivery_claim_token TEXT,
@@ -1643,7 +1890,10 @@ function workflowRunFromRow(row: WorkflowRunQueueRow): WorkflowRunQueueRecord {
1643
1890
  runId: row.run_id,
1644
1891
  workflowName: row.workflow_ref,
1645
1892
  workflowSourceRef: row.workflow_path,
1893
+ workflowSource: parseStoredJson(row.workflow_source_json, "workflow run source"),
1894
+ definitionDigest: row.definition_digest,
1646
1895
  input: parseStoredJson(row.input_json, "workflow run input"),
1896
+ launchOptions: parseStoredJson(row.launch_options_json, "workflow launch options"),
1647
1897
  status: row.status,
1648
1898
  runnerId: row.runner_id,
1649
1899
  claimToken: row.claim_token,
@@ -1651,8 +1901,12 @@ function workflowRunFromRow(row: WorkflowRunQueueRow): WorkflowRunQueueRecord {
1651
1901
  affinityRunnerId: row.affinity_runner_id,
1652
1902
  originSessionId: row.origin_session_id,
1653
1903
  parentRunId: row.parent_run_id,
1904
+ errorCode: row.error_code,
1905
+ errorMessage: row.error_message,
1654
1906
  createdAt: row.created_at,
1655
1907
  updatedAt: row.updated_at,
1908
+ startedAt: row.started_at,
1909
+ finishedAt: row.finished_at,
1656
1910
  };
1657
1911
  }
1658
1912