ai-whisper 0.2.1 → 0.3.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.
@@ -549,6 +549,100 @@ function getCollab(db, collabId2) {
549
549
  });
550
550
  }
551
551
 
552
+ // ../broker/dist/storage/repositories/workflow-repository.js
553
+ function rowToRecord(row) {
554
+ return {
555
+ workflowId: row.workflow_id,
556
+ collabId: row.collab_id,
557
+ workflowType: row.workflow_type,
558
+ name: row.name,
559
+ specPath: row.spec_path,
560
+ roleBindings: JSON.parse(row.role_bindings),
561
+ status: row.status,
562
+ currentPhaseIndex: row.current_phase_index,
563
+ haltReason: row.halt_reason,
564
+ workflowContext: JSON.parse(row.workflow_context),
565
+ createdAt: row.created_at,
566
+ updatedAt: row.updated_at
567
+ };
568
+ }
569
+ function insertWorkflow(db, input) {
570
+ db.prepare(`INSERT INTO workflows
571
+ (workflow_id, collab_id, workflow_type, name, spec_path, role_bindings, status,
572
+ current_phase_index, halt_reason, workflow_context, created_at, updated_at)
573
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?)`).run(input.workflowId, input.collabId, input.workflowType, input.name, input.specPath, JSON.stringify(input.roleBindings), input.status, input.currentPhaseIndex, JSON.stringify(input.workflowContext), input.now, input.now);
574
+ }
575
+ function getWorkflowById(db, workflowId) {
576
+ const row = db.prepare("SELECT * FROM workflows WHERE workflow_id = ?").get(workflowId);
577
+ return row ? rowToRecord(row) : null;
578
+ }
579
+ function listWorkflows(db, filter = {}) {
580
+ const clauses = [];
581
+ const args = [];
582
+ if (filter.collabId) {
583
+ clauses.push("collab_id = ?");
584
+ args.push(filter.collabId);
585
+ }
586
+ if (filter.status) {
587
+ clauses.push("status = ?");
588
+ args.push(filter.status);
589
+ }
590
+ const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
591
+ const rows = db.prepare(`SELECT * FROM workflows ${where} ORDER BY created_at DESC`).all(...args);
592
+ return rows.map(rowToRecord);
593
+ }
594
+ function setWorkflowStatus(db, input) {
595
+ db.prepare(`UPDATE workflows
596
+ SET status = ?, halt_reason = ?, updated_at = ?
597
+ WHERE workflow_id = ?`).run(input.status, input.haltReason, input.now, input.workflowId);
598
+ }
599
+ function updateWorkflowContext(db, input) {
600
+ const existing = getWorkflowById(db, input.workflowId);
601
+ if (!existing) {
602
+ throw new Error(`updateWorkflowContext: unknown workflowId ${input.workflowId}`);
603
+ }
604
+ const merged = { ...existing.workflowContext, ...input.patch };
605
+ db.prepare("UPDATE workflows SET workflow_context = ?, updated_at = ? WHERE workflow_id = ?").run(JSON.stringify(merged), input.now, input.workflowId);
606
+ }
607
+ function incrementCurrentPhaseIndex(db, input) {
608
+ db.prepare(`UPDATE workflows
609
+ SET current_phase_index = current_phase_index + 1, updated_at = ?
610
+ WHERE workflow_id = ?`).run(input.now, input.workflowId);
611
+ }
612
+ function countActiveWorkflowsForCollab(db, collabId2) {
613
+ const row = db.prepare("SELECT COUNT(*) AS n FROM workflows WHERE collab_id = ? AND status IN ('running', 'paused')").get(collabId2);
614
+ return row.n;
615
+ }
616
+
617
+ // ../broker/dist/runtime/workspace-snapshot.js
618
+ import { execFileSync } from "node:child_process";
619
+ function captureWorkspaceSnapshotSync(workspaceRoot) {
620
+ try {
621
+ const ref = execFileSync("git", ["-C", workspaceRoot, "stash", "create"], {
622
+ encoding: "utf8",
623
+ timeout: 1e4
624
+ }).trim();
625
+ if (ref)
626
+ return ref;
627
+ const head = execFileSync("git", ["-C", workspaceRoot, "rev-parse", "HEAD"], {
628
+ encoding: "utf8",
629
+ timeout: 1e4
630
+ }).trim();
631
+ return head || null;
632
+ } catch {
633
+ return null;
634
+ }
635
+ }
636
+ function diffChangedFilesSince(workspaceRoot, sinceRef) {
637
+ try {
638
+ const stdout = execFileSync("git", ["-C", workspaceRoot, "diff", "--name-only", sinceRef], { encoding: "utf8", timeout: 1e4 });
639
+ const files = stdout.split("\n").map((l) => l.trim()).filter((l) => l.length > 0 && !l.startsWith(".ai-whisper/"));
640
+ return [...new Set(files)].sort();
641
+ } catch {
642
+ return [];
643
+ }
644
+ }
645
+
552
646
  // ../broker/dist/storage/repositories/reply-repository.js
553
647
  function insertReply(db, reply) {
554
648
  db.prepare(`INSERT INTO reply (
@@ -1014,7 +1108,7 @@ function upsertRelayTurnState(db, input) {
1014
1108
  }
1015
1109
 
1016
1110
  // ../broker/dist/storage/repositories/relay-capture-diagnostics-repository.js
1017
- function rowToRecord(row) {
1111
+ function rowToRecord2(row) {
1018
1112
  return {
1019
1113
  captureId: row.capture_id,
1020
1114
  handoffId: row.handoff_id,
@@ -1051,32 +1145,32 @@ function listCaptureDiagnosticsByCollab(db, collabId2, limit, opts) {
1051
1145
  const rows2 = db.prepare(`SELECT * FROM relay_capture_diagnostics
1052
1146
  WHERE collab_id = ?${filterClause}
1053
1147
  ORDER BY created_at DESC`).all(collabId2, ...filterArgs);
1054
- return rows2.map(rowToRecord);
1148
+ return rows2.map(rowToRecord2);
1055
1149
  }
1056
1150
  const rows = db.prepare(`SELECT * FROM relay_capture_diagnostics
1057
1151
  WHERE collab_id = ?${filterClause}
1058
1152
  ORDER BY created_at DESC
1059
1153
  LIMIT ?`).all(collabId2, ...filterArgs, limit);
1060
- return rows.map(rowToRecord);
1154
+ return rows.map(rowToRecord2);
1061
1155
  }
1062
1156
  function listCaptureDiagnosticsByCollabAndChain(db, collabId2, chainId, limit) {
1063
1157
  if (limit === null) {
1064
1158
  const rows2 = db.prepare(`SELECT * FROM relay_capture_diagnostics
1065
1159
  WHERE collab_id = ? AND chain_id = ?
1066
1160
  ORDER BY created_at DESC`).all(collabId2, chainId);
1067
- return rows2.map(rowToRecord);
1161
+ return rows2.map(rowToRecord2);
1068
1162
  }
1069
1163
  const rows = db.prepare(`SELECT * FROM relay_capture_diagnostics
1070
1164
  WHERE collab_id = ? AND chain_id = ?
1071
1165
  ORDER BY created_at DESC
1072
1166
  LIMIT ?`).all(collabId2, chainId, limit);
1073
- return rows.map(rowToRecord);
1167
+ return rows.map(rowToRecord2);
1074
1168
  }
1075
1169
  function listCaptureDiagnosticsByHandoff(db, handoffId) {
1076
1170
  const rows = db.prepare(`SELECT * FROM relay_capture_diagnostics
1077
1171
  WHERE handoff_id = ?
1078
1172
  ORDER BY created_at ASC`).all(handoffId);
1079
- return rows.map(rowToRecord);
1173
+ return rows.map(rowToRecord2);
1080
1174
  }
1081
1175
  function deleteCaptureDiagnosticsOlderThan(db, cutoffIso) {
1082
1176
  const result = db.prepare("DELETE FROM relay_capture_diagnostics WHERE created_at < ?").run(cutoffIso);
@@ -1084,7 +1178,7 @@ function deleteCaptureDiagnosticsOlderThan(db, cutoffIso) {
1084
1178
  }
1085
1179
 
1086
1180
  // ../broker/dist/storage/repositories/relay-evaluator-diagnostics-repository.js
1087
- function rowToRecord2(row) {
1181
+ function rowToRecord3(row) {
1088
1182
  return {
1089
1183
  evaluatorId: row.evaluator_id,
1090
1184
  handoffId: row.handoff_id,
@@ -1129,32 +1223,32 @@ function listEvaluatorDiagnosticsByCollab(db, collabId2, limit, opts) {
1129
1223
  const rows2 = db.prepare(`SELECT * FROM relay_evaluator_diagnostics
1130
1224
  WHERE collab_id = ?${filterClause}
1131
1225
  ORDER BY created_at DESC`).all(collabId2, ...filterArgs);
1132
- return rows2.map(rowToRecord2);
1226
+ return rows2.map(rowToRecord3);
1133
1227
  }
1134
1228
  const rows = db.prepare(`SELECT * FROM relay_evaluator_diagnostics
1135
1229
  WHERE collab_id = ?${filterClause}
1136
1230
  ORDER BY created_at DESC
1137
1231
  LIMIT ?`).all(collabId2, ...filterArgs, limit);
1138
- return rows.map(rowToRecord2);
1232
+ return rows.map(rowToRecord3);
1139
1233
  }
1140
1234
  function listEvaluatorDiagnosticsByCollabAndChain(db, collabId2, chainId, limit) {
1141
1235
  if (limit === null) {
1142
1236
  const rows2 = db.prepare(`SELECT * FROM relay_evaluator_diagnostics
1143
1237
  WHERE collab_id = ? AND chain_id = ?
1144
1238
  ORDER BY created_at DESC`).all(collabId2, chainId);
1145
- return rows2.map(rowToRecord2);
1239
+ return rows2.map(rowToRecord3);
1146
1240
  }
1147
1241
  const rows = db.prepare(`SELECT * FROM relay_evaluator_diagnostics
1148
1242
  WHERE collab_id = ? AND chain_id = ?
1149
1243
  ORDER BY created_at DESC
1150
1244
  LIMIT ?`).all(collabId2, chainId, limit);
1151
- return rows.map(rowToRecord2);
1245
+ return rows.map(rowToRecord3);
1152
1246
  }
1153
1247
  function listEvaluatorDiagnosticsByHandoff(db, handoffId) {
1154
1248
  const rows = db.prepare(`SELECT * FROM relay_evaluator_diagnostics
1155
1249
  WHERE handoff_id = ?
1156
1250
  ORDER BY created_at ASC`).all(handoffId);
1157
- return rows.map(rowToRecord2);
1251
+ return rows.map(rowToRecord3);
1158
1252
  }
1159
1253
  function deleteEvaluatorDiagnosticsOlderThan(db, cutoffIso) {
1160
1254
  const result = db.prepare("DELETE FROM relay_evaluator_diagnostics WHERE created_at < ?").run(cutoffIso);
@@ -1162,7 +1256,7 @@ function deleteEvaluatorDiagnosticsOlderThan(db, cutoffIso) {
1162
1256
  }
1163
1257
 
1164
1258
  // ../broker/dist/storage/repositories/relay-handoff-repository.js
1165
- function rowToRecord3(row) {
1259
+ function rowToRecord4(row) {
1166
1260
  return {
1167
1261
  handoffId: row.handoff_id,
1168
1262
  collabId: row.collab_id,
@@ -1199,7 +1293,7 @@ function queryRelayHandoff(db, handoffId) {
1199
1293
  FROM relay_handoff h
1200
1294
  JOIN collab c ON c.collab_id = h.collab_id
1201
1295
  WHERE h.handoff_id = ?`).get(handoffId);
1202
- return row ? rowToRecord3(row) : null;
1296
+ return row ? rowToRecord4(row) : null;
1203
1297
  }
1204
1298
  function createRelayHandoffTxn(db, input) {
1205
1299
  return db.transaction(() => {
@@ -1351,7 +1445,7 @@ function queryLatestHandedBackHandoff(db, collabId2) {
1351
1445
  WHERE h.collab_id = ? AND h.status = 'handed_back'
1352
1446
  ORDER BY h.resolved_at DESC
1353
1447
  LIMIT 1`).get(collabId2);
1354
- return row ? rowToRecord3(row) : null;
1448
+ return row ? rowToRecord4(row) : null;
1355
1449
  }
1356
1450
  function failRelayHandoffOnDisconnectTxn(db, input) {
1357
1451
  db.transaction(() => {
@@ -1375,6 +1469,30 @@ function failRelayHandoffOnDisconnectTxn(db, input) {
1375
1469
  }
1376
1470
  })();
1377
1471
  }
1472
+ function hasInFlightAcceptedHandoffForWorkflow(db, workflowId) {
1473
+ const row = db.prepare("SELECT COUNT(*) AS n FROM relay_handoff WHERE workflow_id = ? AND status = 'accepted'").get(workflowId);
1474
+ return row.n > 0;
1475
+ }
1476
+ function resetStrandedOrchestrationForWorkflow(db, workflowId) {
1477
+ const result = db.prepare(`UPDATE relay_handoff
1478
+ SET orchestrator_status = 'idle', orchestrator_claimed_at = NULL
1479
+ WHERE workflow_id = ?
1480
+ AND status = 'handed_back'
1481
+ AND orchestrator_status = 'pending'
1482
+ AND evaluator_verdict IS NULL`).run(workflowId);
1483
+ return result.changes;
1484
+ }
1485
+ function prependToPendingHandoffRequestText(db, input) {
1486
+ const row = db.prepare(`SELECT handoff_id, request_text FROM relay_handoff
1487
+ WHERE workflow_id = ? AND status = 'pending'
1488
+ ORDER BY created_at DESC LIMIT 1`).get(input.workflowId);
1489
+ if (!row)
1490
+ return false;
1491
+ db.prepare("UPDATE relay_handoff SET request_text = ?, last_activity_at = ? WHERE handoff_id = ?").run(`${input.prefix}
1492
+
1493
+ ${row.request_text}`, input.now, row.handoff_id);
1494
+ return true;
1495
+ }
1378
1496
  function claimRelayHandoffForOrchestrationTxn(db, input) {
1379
1497
  const result = db.prepare(`UPDATE relay_handoff
1380
1498
  SET orchestrator_status = 'pending',
@@ -1393,10 +1511,12 @@ function listRelayHandoffsPendingOrchestration(db, collabId2) {
1393
1511
  c.orchestrator_max_rounds AS max_rounds
1394
1512
  FROM relay_handoff h
1395
1513
  JOIN collab c ON c.collab_id = h.collab_id
1514
+ LEFT JOIN workflows w ON w.workflow_id = h.workflow_id
1396
1515
  WHERE h.collab_id = ?
1397
1516
  AND h.status = 'handed_back'
1398
- AND h.orchestrator_status = 'idle'`).all(collabId2);
1399
- return rows.map(rowToRecord3);
1517
+ AND h.orchestrator_status = 'idle'
1518
+ AND (w.status IS NULL OR w.status <> 'paused')`).all(collabId2);
1519
+ return rows.map(rowToRecord4);
1400
1520
  }
1401
1521
  function createLoopRelayHandoffTxn(db, input) {
1402
1522
  return db.transaction(() => {
@@ -1592,7 +1712,7 @@ function getHandoffWithWorkflowMetaById(db, handoffId) {
1592
1712
  WHERE h.handoff_id = ?`).get(handoffId);
1593
1713
  if (!row)
1594
1714
  return null;
1595
- const base = rowToRecord3(row);
1715
+ const base = rowToRecord4(row);
1596
1716
  return {
1597
1717
  ...base,
1598
1718
  handoffStep: row.handoff_step,
@@ -1842,71 +1962,6 @@ function listRunCostRows(db, input) {
1842
1962
  // ../broker/dist/control/workflow-control.js
1843
1963
  import { randomUUID } from "node:crypto";
1844
1964
 
1845
- // ../broker/dist/storage/repositories/workflow-repository.js
1846
- function rowToRecord4(row) {
1847
- return {
1848
- workflowId: row.workflow_id,
1849
- collabId: row.collab_id,
1850
- workflowType: row.workflow_type,
1851
- name: row.name,
1852
- specPath: row.spec_path,
1853
- roleBindings: JSON.parse(row.role_bindings),
1854
- status: row.status,
1855
- currentPhaseIndex: row.current_phase_index,
1856
- haltReason: row.halt_reason,
1857
- workflowContext: JSON.parse(row.workflow_context),
1858
- createdAt: row.created_at,
1859
- updatedAt: row.updated_at
1860
- };
1861
- }
1862
- function insertWorkflow(db, input) {
1863
- db.prepare(`INSERT INTO workflows
1864
- (workflow_id, collab_id, workflow_type, name, spec_path, role_bindings, status,
1865
- current_phase_index, halt_reason, workflow_context, created_at, updated_at)
1866
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?)`).run(input.workflowId, input.collabId, input.workflowType, input.name, input.specPath, JSON.stringify(input.roleBindings), input.status, input.currentPhaseIndex, JSON.stringify(input.workflowContext), input.now, input.now);
1867
- }
1868
- function getWorkflowById(db, workflowId) {
1869
- const row = db.prepare("SELECT * FROM workflows WHERE workflow_id = ?").get(workflowId);
1870
- return row ? rowToRecord4(row) : null;
1871
- }
1872
- function listWorkflows(db, filter = {}) {
1873
- const clauses = [];
1874
- const args = [];
1875
- if (filter.collabId) {
1876
- clauses.push("collab_id = ?");
1877
- args.push(filter.collabId);
1878
- }
1879
- if (filter.status) {
1880
- clauses.push("status = ?");
1881
- args.push(filter.status);
1882
- }
1883
- const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
1884
- const rows = db.prepare(`SELECT * FROM workflows ${where} ORDER BY created_at DESC`).all(...args);
1885
- return rows.map(rowToRecord4);
1886
- }
1887
- function setWorkflowStatus(db, input) {
1888
- db.prepare(`UPDATE workflows
1889
- SET status = ?, halt_reason = ?, updated_at = ?
1890
- WHERE workflow_id = ?`).run(input.status, input.haltReason, input.now, input.workflowId);
1891
- }
1892
- function updateWorkflowContext(db, input) {
1893
- const existing = getWorkflowById(db, input.workflowId);
1894
- if (!existing) {
1895
- throw new Error(`updateWorkflowContext: unknown workflowId ${input.workflowId}`);
1896
- }
1897
- const merged = { ...existing.workflowContext, ...input.patch };
1898
- db.prepare("UPDATE workflows SET workflow_context = ?, updated_at = ? WHERE workflow_id = ?").run(JSON.stringify(merged), input.now, input.workflowId);
1899
- }
1900
- function incrementCurrentPhaseIndex(db, input) {
1901
- db.prepare(`UPDATE workflows
1902
- SET current_phase_index = current_phase_index + 1, updated_at = ?
1903
- WHERE workflow_id = ?`).run(input.now, input.workflowId);
1904
- }
1905
- function countRunningWorkflowsForCollab(db, collabId2) {
1906
- const row = db.prepare("SELECT COUNT(*) AS n FROM workflows WHERE collab_id = ? AND status = 'running'").get(collabId2);
1907
- return row.n;
1908
- }
1909
-
1910
1965
  // ../broker/dist/storage/repositories/workflow-phase-repository.js
1911
1966
  function rowToRecord5(row) {
1912
1967
  return {
@@ -2027,6 +2082,14 @@ Findings: (omit this block entirely if none)
2027
2082
  Non-blocking risks:
2028
2083
  - <quality risk that does NOT block this gate, or "None.">
2029
2084
  --- end protocol ---`;
2085
+ var WORKFLOW_OPERATOR_CONTROL = `--- ai-whisper operator control ---
2086
+ If the operator interrupts you and asks to pause the workflow (e.g. "pause the workflow, I need to fix X"):
2087
+ 1. Find the active workflow id: run \`whisper workflow list\`.
2088
+ 2. Run \`whisper workflow pause <id>\`.
2089
+ 3. Acknowledge and STOP working \u2014 do not start the next change.
2090
+ The operator will edit artifacts and later run \`whisper workflow resume <id> [--message "..."]\`; you will then receive a notice of what changed and must re-read those files before continuing.
2091
+ Provider note: the Codex CLI EXITS its session on Ctrl+C at an idle prompt (a mid-task Ctrl+C only interrupts the running task). The operator typically interrupts you while you are BUSY before issuing this instruction; do not assume Ctrl+C is a safe no-op.
2092
+ --- end operator control ---`;
2030
2093
  var WORKFLOW_DIAGNOSIS_PROTOCOL = `--- ai-whisper diagnosis review protocol ---
2031
2094
  You are the gatekeeper for an ai-whisper complex-bug-fixing DIAGNOSIS gate. No human is in the loop. Do NOT trust the implementer's diagnosis \u2014 verify it yourself. The gate stays shut until YOU independently agree on the root cause and that the fix is net-safe.
2032
2095
 
@@ -2265,10 +2328,21 @@ var COMPLEX_BUG_FIXING = {
2265
2328
  }
2266
2329
  ]
2267
2330
  };
2331
+ function withOperatorControl(def) {
2332
+ return {
2333
+ ...def,
2334
+ phases: def.phases.map((p) => ({
2335
+ ...p,
2336
+ kickoffTemplate: `${p.kickoffTemplate}
2337
+
2338
+ ${WORKFLOW_OPERATOR_CONTROL}`
2339
+ }))
2340
+ };
2341
+ }
2268
2342
  var REGISTRY = {
2269
- [SPEC_DRIVEN_DEVELOPMENT.type]: SPEC_DRIVEN_DEVELOPMENT,
2270
- [RALPH_LOOP.type]: RALPH_LOOP,
2271
- [COMPLEX_BUG_FIXING.type]: COMPLEX_BUG_FIXING
2343
+ [SPEC_DRIVEN_DEVELOPMENT.type]: withOperatorControl(SPEC_DRIVEN_DEVELOPMENT),
2344
+ [RALPH_LOOP.type]: withOperatorControl(RALPH_LOOP),
2345
+ [COMPLEX_BUG_FIXING.type]: withOperatorControl(COMPLEX_BUG_FIXING)
2272
2346
  };
2273
2347
  function ralphRunDir(workspaceRoot, workflowId) {
2274
2348
  return join(workspaceRoot, ".ai-whisper", "ralph", workflowId);
@@ -2324,6 +2398,23 @@ function safeDerivePlanPath(specPath, createdAt) {
2324
2398
  return `${dir}${stem}.plan.md`;
2325
2399
  }
2326
2400
  }
2401
+ function composeResumeNotice(input) {
2402
+ const hasFiles = input.changedFiles.length > 0;
2403
+ const hasMessage = !!input.message && input.message.trim().length > 0;
2404
+ if (!hasFiles && !hasMessage)
2405
+ return null;
2406
+ const lines = [];
2407
+ if (hasFiles) {
2408
+ lines.push("While paused, the operator modified these files:");
2409
+ for (const f of input.changedFiles)
2410
+ lines.push(` - ${f}`);
2411
+ lines.push("Re-read them before continuing.");
2412
+ }
2413
+ if (hasMessage)
2414
+ lines.push(`Operator note: ${input.message.trim()}`);
2415
+ lines.push("Re-evaluate whether your current direction still holds; correct course before proceeding.");
2416
+ return lines.join("\n");
2417
+ }
2327
2418
  function createWorkflowControl(deps) {
2328
2419
  const { db, events } = deps;
2329
2420
  function createWorkflow(input) {
@@ -2346,8 +2437,9 @@ function createWorkflowControl(deps) {
2346
2437
  }
2347
2438
  const workflowId = `wf_${randomUUID().replace(/-/g, "").slice(0, 16)}`;
2348
2439
  const tx = db.transaction(() => {
2349
- if (countRunningWorkflowsForCollab(db, input.collabId) > 0) {
2350
- throw new Error(`another workflow is already running on this collab (${input.collabId})`);
2440
+ if (countActiveWorkflowsForCollab(db, input.collabId) > 0) {
2441
+ const active = listWorkflows(db, { collabId: input.collabId }).find((w) => w.status === "running" || w.status === "paused");
2442
+ throw new Error(`another workflow is already active on this collab (${input.collabId})` + (active ? `: ${active.workflowId} (${active.status})` : ""));
2351
2443
  }
2352
2444
  insertWorkflow(db, {
2353
2445
  workflowId,
@@ -2515,6 +2607,24 @@ function createWorkflowControl(deps) {
2515
2607
  postmortemPath: bf.postmortemPath
2516
2608
  });
2517
2609
  }
2610
+ function consumeResumeNotice(input) {
2611
+ const wf = getWorkflowById(db, input.workflowId);
2612
+ const notice = wf?.workflowContext?.resumeNotice ?? null;
2613
+ if (notice) {
2614
+ updateWorkflowContext(db, {
2615
+ workflowId: input.workflowId,
2616
+ patch: { resumeNotice: null },
2617
+ now: input.now
2618
+ });
2619
+ }
2620
+ return notice;
2621
+ }
2622
+ function withResumeNotice(workflowId, requestText, now) {
2623
+ const notice = consumeResumeNotice({ workflowId, now });
2624
+ return notice ? `${notice}
2625
+
2626
+ ${requestText}` : requestText;
2627
+ }
2518
2628
  function createContinuationHandoff(input) {
2519
2629
  const handoffId = `ho_${randomUUID().replace(/-/g, "").slice(0, 16)}`;
2520
2630
  insertWorkflowOwnedRelayHandoff(db, {
@@ -2522,7 +2632,9 @@ function createWorkflowControl(deps) {
2522
2632
  collabId: input.workflow.collabId,
2523
2633
  senderAgent: input.sender,
2524
2634
  targetAgent: input.target,
2525
- requestText: input.requestText,
2635
+ // Prepend the one-time resume notice (if any) to the FIRST outgoing request
2636
+ // after a resume, then it is cleared — so the agent re-reads changed files.
2637
+ requestText: withResumeNotice(input.workflow.workflowId, input.requestText, input.now),
2526
2638
  chainId: input.chain.chainId,
2527
2639
  roundNumber: input.incrementRound ? input.chain.currentRound + 1 : input.chain.currentRound,
2528
2640
  maxRounds: input.chain.maxRounds,
@@ -2582,7 +2694,9 @@ function createWorkflowControl(deps) {
2582
2694
  collabId: input.workflow.collabId,
2583
2695
  senderAgent: sender,
2584
2696
  targetAgent: target,
2585
- requestText: kickoffText,
2697
+ // Prepend a pending resume notice (consumed once) so a phase-advance kickoff
2698
+ // after a resume also carries the operator's change notice.
2699
+ requestText: withResumeNotice(input.workflow.workflowId, kickoffText, input.now),
2586
2700
  chainId,
2587
2701
  roundNumber: 1,
2588
2702
  maxRounds: phase.maxRounds,
@@ -3043,20 +3157,100 @@ ${findingsText}`;
3043
3157
  reason: input.reason
3044
3158
  });
3045
3159
  }
3160
+ function maybeCaptureQuiesceSnapshot(input) {
3161
+ const wf = getWorkflowById(db, input.workflowId);
3162
+ if (!wf || wf.status !== "paused")
3163
+ return;
3164
+ if (wf.workflowContext.pauseSnapshotRef !== void 0) {
3165
+ return;
3166
+ }
3167
+ if (hasInFlightAcceptedHandoffForWorkflow(db, input.workflowId))
3168
+ return;
3169
+ const ref = deps.captureSnapshotRef ? deps.captureSnapshotRef(input.workflowId) : null;
3170
+ updateWorkflowContext(db, {
3171
+ workflowId: input.workflowId,
3172
+ patch: { pauseSnapshotRef: ref },
3173
+ now: input.now
3174
+ });
3175
+ }
3176
+ function pauseWorkflow(input) {
3177
+ const workflow = getWorkflowById(db, input.workflowId);
3178
+ if (!workflow) {
3179
+ throw new Error(`pauseWorkflow: unknown workflowId ${input.workflowId}`);
3180
+ }
3181
+ if (workflow.status !== "running") {
3182
+ throw new Error(`pauseWorkflow: workflow ${input.workflowId} is ${workflow.status}, only running workflows can be paused`);
3183
+ }
3184
+ const tx = db.transaction(() => {
3185
+ const current = getWorkflowById(db, input.workflowId);
3186
+ if (!current || current.status !== "running")
3187
+ return;
3188
+ setWorkflowStatus(db, {
3189
+ workflowId: input.workflowId,
3190
+ status: "paused",
3191
+ haltReason: null,
3192
+ now: input.now
3193
+ });
3194
+ updateWorkflowContext(db, {
3195
+ workflowId: input.workflowId,
3196
+ patch: { pausedAt: input.now },
3197
+ now: input.now
3198
+ });
3199
+ });
3200
+ tx.immediate();
3201
+ events.emit("workflow.paused", { workflowId: input.workflowId });
3202
+ maybeCaptureQuiesceSnapshot({ workflowId: input.workflowId, now: input.now });
3203
+ }
3204
+ function resumeHaltedWorkflow(workflow, now) {
3205
+ const tx = db.transaction(() => {
3206
+ const others = listWorkflows(db, { collabId: workflow.collabId }).filter((w) => w.workflowId !== workflow.workflowId && (w.status === "running" || w.status === "paused"));
3207
+ if (others.length > 0) {
3208
+ throw new Error(`resumeWorkflow: another workflow is already active on collab ${workflow.collabId}`);
3209
+ }
3210
+ setWorkflowStatus(db, {
3211
+ workflowId: workflow.workflowId,
3212
+ status: "running",
3213
+ haltReason: null,
3214
+ now
3215
+ });
3216
+ });
3217
+ tx.immediate();
3218
+ events.emit("workflow.resumed", {
3219
+ workflowId: workflow.workflowId,
3220
+ phaseIndex: workflow.currentPhaseIndex
3221
+ });
3222
+ }
3046
3223
  function resumeWorkflow(input) {
3047
3224
  const workflow = getWorkflowById(db, input.workflowId);
3048
3225
  if (!workflow) {
3049
3226
  throw new Error(`resumeWorkflow: unknown workflowId ${input.workflowId}`);
3050
3227
  }
3051
- if (workflow.status === "canceled") {
3052
- throw new Error(`resumeWorkflow: workflow ${input.workflowId} is canceled and cannot be resumed`);
3228
+ if (workflow.status === "halted") {
3229
+ resumeHaltedWorkflow(workflow, input.now);
3230
+ return;
3053
3231
  }
3054
- if (workflow.status !== "halted") {
3055
- throw new Error(`resumeWorkflow: workflow ${input.workflowId} is not halted (status=${workflow.status})`);
3232
+ if (workflow.status !== "paused") {
3233
+ throw new Error(`resumeWorkflow: workflow ${input.workflowId} is ${workflow.status}, only paused or halted workflows can be resumed`);
3056
3234
  }
3235
+ const ref = workflow.workflowContext.pauseSnapshotRef ?? null;
3236
+ const changedFiles = ref && deps.diffChangedFilesSinceSnapshot ? deps.diffChangedFilesSinceSnapshot(input.workflowId, ref) : [];
3237
+ const notice = composeResumeNotice({
3238
+ changedFiles,
3239
+ message: input.message ?? null
3240
+ });
3057
3241
  const tx = db.transaction(() => {
3058
- if (countRunningWorkflowsForCollab(db, workflow.collabId) > 0) {
3059
- throw new Error(`resumeWorkflow: another workflow is already running on collab ${workflow.collabId}`);
3242
+ const others = listWorkflows(db, { collabId: workflow.collabId }).filter((w) => w.workflowId !== input.workflowId && (w.status === "running" || w.status === "paused"));
3243
+ if (others.length > 0) {
3244
+ throw new Error(`resumeWorkflow: another workflow is already active on collab ${workflow.collabId}`);
3245
+ }
3246
+ resetStrandedOrchestrationForWorkflow(db, input.workflowId);
3247
+ let bakedIntoPending = false;
3248
+ if (notice) {
3249
+ bakedIntoPending = prependToPendingHandoffRequestText(db, {
3250
+ workflowId: input.workflowId,
3251
+ prefix: notice,
3252
+ now: input.now
3253
+ });
3060
3254
  }
3061
3255
  setWorkflowStatus(db, {
3062
3256
  workflowId: input.workflowId,
@@ -3064,6 +3258,15 @@ ${findingsText}`;
3064
3258
  haltReason: null,
3065
3259
  now: input.now
3066
3260
  });
3261
+ updateWorkflowContext(db, {
3262
+ workflowId: input.workflowId,
3263
+ patch: {
3264
+ resumeNotice: bakedIntoPending ? null : notice,
3265
+ pausedAt: null,
3266
+ pauseSnapshotRef: null
3267
+ },
3268
+ now: input.now
3269
+ });
3067
3270
  });
3068
3271
  tx.immediate();
3069
3272
  events.emit("workflow.resumed", {
@@ -3164,6 +3367,9 @@ ${findingsText}`;
3164
3367
  beginPhaseRun,
3165
3368
  applyOrchestratorVerdict,
3166
3369
  haltWorkflow,
3370
+ pauseWorkflow,
3371
+ maybeCaptureQuiesceSnapshot,
3372
+ consumeResumeNotice,
3167
3373
  resumeWorkflow,
3168
3374
  cancelWorkflow,
3169
3375
  getHandoffWithWorkflowMeta,
@@ -3179,8 +3385,32 @@ function buildEventId(kind, subjectId, timestamp) {
3179
3385
  return `evt_${kind}_${subjectId}_${normalizeTimestampForEventId(timestamp)}`;
3180
3386
  }
3181
3387
  function createControlService(db, events) {
3182
- const workflowControl = createWorkflowControl({ db, events });
3388
+ const resolveWorkspaceRoot = (workflowId) => {
3389
+ const wf = getWorkflowById(db, workflowId);
3390
+ if (!wf)
3391
+ return null;
3392
+ return getCollab(db, wf.collabId)?.workspaceRoot ?? null;
3393
+ };
3394
+ const workflowControl = createWorkflowControl({
3395
+ db,
3396
+ events,
3397
+ captureSnapshotRef: (workflowId) => {
3398
+ const root = resolveWorkspaceRoot(workflowId);
3399
+ return root ? captureWorkspaceSnapshotSync(root) : null;
3400
+ },
3401
+ diffChangedFilesSinceSnapshot: (workflowId, sinceRef) => {
3402
+ const root = resolveWorkspaceRoot(workflowId);
3403
+ return root ? diffChangedFilesSince(root, sinceRef) : [];
3404
+ }
3405
+ });
3406
+ const isWorkflowDeliverySuspended = (handoffId) => {
3407
+ const meta = workflowControl.getHandoffWithWorkflowMeta(handoffId);
3408
+ if (!meta?.workflowId)
3409
+ return false;
3410
+ return workflowControl.getWorkflow(meta.workflowId)?.status === "paused";
3411
+ };
3183
3412
  return Object.assign({
3413
+ isWorkflowDeliverySuspended,
3184
3414
  startCollab(input) {
3185
3415
  const collab2 = collabSchema.parse({
3186
3416
  version: 1,
@@ -3808,6 +4038,8 @@ function createControlService(db, events) {
3808
4038
  return createRelayHandoffTxn(db, input);
3809
4039
  },
3810
4040
  acceptRelayHandoff(input) {
4041
+ if (isWorkflowDeliverySuspended(input.handoffId))
4042
+ return;
3811
4043
  return acceptRelayHandoffTxn(db, input);
3812
4044
  },
3813
4045
  deferRelayHandoff(input) {
@@ -3820,7 +4052,12 @@ function createControlService(db, events) {
3820
4052
  return markRelayHandoffStaleTxn(db, input);
3821
4053
  },
3822
4054
  handoffBackRelay(input) {
3823
- return handoffBackRelayTxn(db, input);
4055
+ const result = handoffBackRelayTxn(db, input);
4056
+ const meta = workflowControl.getHandoffWithWorkflowMeta(input.handoffId);
4057
+ if (meta?.workflowId && workflowControl.getWorkflow(meta.workflowId)?.status === "paused") {
4058
+ workflowControl.maybeCaptureQuiesceSnapshot({ workflowId: meta.workflowId, now: input.now });
4059
+ }
4060
+ return result;
3824
4061
  },
3825
4062
  failRelayHandoffOnDisconnect(input) {
3826
4063
  return failRelayHandoffOnDisconnectTxn(db, input);
@@ -3832,6 +4069,8 @@ function createControlService(db, events) {
3832
4069
  return queryLatestHandedBackHandoff(db, collabId2);
3833
4070
  },
3834
4071
  claimRelayHandoffForOrchestration(input) {
4072
+ if (isWorkflowDeliverySuspended(input.handoffId))
4073
+ return null;
3835
4074
  return claimRelayHandoffForOrchestrationTxn(db, input);
3836
4075
  },
3837
4076
  listRelayHandoffsPendingOrchestration(collabId2) {
@@ -4285,8 +4524,9 @@ CREATE TABLE IF NOT EXISTS workflows (
4285
4524
  updated_at TEXT NOT NULL
4286
4525
  );
4287
4526
 
4527
+ DROP INDEX IF EXISTS workflows_one_running_per_collab;
4288
4528
  CREATE UNIQUE INDEX IF NOT EXISTS workflows_one_running_per_collab
4289
- ON workflows(collab_id) WHERE status = 'running';
4529
+ ON workflows(collab_id) WHERE status IN ('running', 'paused');
4290
4530
 
4291
4531
  CREATE TABLE IF NOT EXISTS workflow_phases (
4292
4532
  phase_run_id TEXT PRIMARY KEY,
@@ -5820,7 +6060,7 @@ ${JSON.stringify(event.payload)}`;
5820
6060
  }
5821
6061
 
5822
6062
  // src/runtime/process-start-time.ts
5823
- import { execFileSync } from "node:child_process";
6063
+ import { execFileSync as execFileSync2 } from "node:child_process";
5824
6064
  import { readFileSync } from "node:fs";
5825
6065
  function readProcessStartTime(pid) {
5826
6066
  if (process.platform === "linux") {
@@ -5834,7 +6074,7 @@ function readProcessStartTime(pid) {
5834
6074
  }
5835
6075
  if (process.platform === "darwin") {
5836
6076
  try {
5837
- const out = execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], {
6077
+ const out = execFileSync2("ps", ["-o", "lstart=", "-p", String(pid)], {
5838
6078
  encoding: "utf8"
5839
6079
  });
5840
6080
  const trimmed = out.trim();