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.
@@ -582,6 +582,100 @@ function getCollab(db, collabId) {
582
582
  });
583
583
  }
584
584
 
585
+ // ../broker/dist/storage/repositories/workflow-repository.js
586
+ function rowToRecord(row) {
587
+ return {
588
+ workflowId: row.workflow_id,
589
+ collabId: row.collab_id,
590
+ workflowType: row.workflow_type,
591
+ name: row.name,
592
+ specPath: row.spec_path,
593
+ roleBindings: JSON.parse(row.role_bindings),
594
+ status: row.status,
595
+ currentPhaseIndex: row.current_phase_index,
596
+ haltReason: row.halt_reason,
597
+ workflowContext: JSON.parse(row.workflow_context),
598
+ createdAt: row.created_at,
599
+ updatedAt: row.updated_at
600
+ };
601
+ }
602
+ function insertWorkflow(db, input) {
603
+ db.prepare(`INSERT INTO workflows
604
+ (workflow_id, collab_id, workflow_type, name, spec_path, role_bindings, status,
605
+ current_phase_index, halt_reason, workflow_context, created_at, updated_at)
606
+ 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);
607
+ }
608
+ function getWorkflowById(db, workflowId) {
609
+ const row = db.prepare("SELECT * FROM workflows WHERE workflow_id = ?").get(workflowId);
610
+ return row ? rowToRecord(row) : null;
611
+ }
612
+ function listWorkflows(db, filter = {}) {
613
+ const clauses = [];
614
+ const args = [];
615
+ if (filter.collabId) {
616
+ clauses.push("collab_id = ?");
617
+ args.push(filter.collabId);
618
+ }
619
+ if (filter.status) {
620
+ clauses.push("status = ?");
621
+ args.push(filter.status);
622
+ }
623
+ const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
624
+ const rows = db.prepare(`SELECT * FROM workflows ${where} ORDER BY created_at DESC`).all(...args);
625
+ return rows.map(rowToRecord);
626
+ }
627
+ function setWorkflowStatus(db, input) {
628
+ db.prepare(`UPDATE workflows
629
+ SET status = ?, halt_reason = ?, updated_at = ?
630
+ WHERE workflow_id = ?`).run(input.status, input.haltReason, input.now, input.workflowId);
631
+ }
632
+ function updateWorkflowContext(db, input) {
633
+ const existing = getWorkflowById(db, input.workflowId);
634
+ if (!existing) {
635
+ throw new Error(`updateWorkflowContext: unknown workflowId ${input.workflowId}`);
636
+ }
637
+ const merged = { ...existing.workflowContext, ...input.patch };
638
+ db.prepare("UPDATE workflows SET workflow_context = ?, updated_at = ? WHERE workflow_id = ?").run(JSON.stringify(merged), input.now, input.workflowId);
639
+ }
640
+ function incrementCurrentPhaseIndex(db, input) {
641
+ db.prepare(`UPDATE workflows
642
+ SET current_phase_index = current_phase_index + 1, updated_at = ?
643
+ WHERE workflow_id = ?`).run(input.now, input.workflowId);
644
+ }
645
+ function countActiveWorkflowsForCollab(db, collabId) {
646
+ const row = db.prepare("SELECT COUNT(*) AS n FROM workflows WHERE collab_id = ? AND status IN ('running', 'paused')").get(collabId);
647
+ return row.n;
648
+ }
649
+
650
+ // ../broker/dist/runtime/workspace-snapshot.js
651
+ import { execFileSync } from "node:child_process";
652
+ function captureWorkspaceSnapshotSync(workspaceRoot) {
653
+ try {
654
+ const ref = execFileSync("git", ["-C", workspaceRoot, "stash", "create"], {
655
+ encoding: "utf8",
656
+ timeout: 1e4
657
+ }).trim();
658
+ if (ref)
659
+ return ref;
660
+ const head = execFileSync("git", ["-C", workspaceRoot, "rev-parse", "HEAD"], {
661
+ encoding: "utf8",
662
+ timeout: 1e4
663
+ }).trim();
664
+ return head || null;
665
+ } catch {
666
+ return null;
667
+ }
668
+ }
669
+ function diffChangedFilesSince(workspaceRoot, sinceRef) {
670
+ try {
671
+ const stdout = execFileSync("git", ["-C", workspaceRoot, "diff", "--name-only", sinceRef], { encoding: "utf8", timeout: 1e4 });
672
+ const files = stdout.split("\n").map((l) => l.trim()).filter((l) => l.length > 0 && !l.startsWith(".ai-whisper/"));
673
+ return [...new Set(files)].sort();
674
+ } catch {
675
+ return [];
676
+ }
677
+ }
678
+
585
679
  // ../broker/dist/storage/repositories/reply-repository.js
586
680
  function insertReply(db, reply) {
587
681
  db.prepare(`INSERT INTO reply (
@@ -1047,7 +1141,7 @@ function upsertRelayTurnState(db, input) {
1047
1141
  }
1048
1142
 
1049
1143
  // ../broker/dist/storage/repositories/relay-capture-diagnostics-repository.js
1050
- function rowToRecord(row) {
1144
+ function rowToRecord2(row) {
1051
1145
  return {
1052
1146
  captureId: row.capture_id,
1053
1147
  handoffId: row.handoff_id,
@@ -1084,32 +1178,32 @@ function listCaptureDiagnosticsByCollab(db, collabId, limit, opts) {
1084
1178
  const rows2 = db.prepare(`SELECT * FROM relay_capture_diagnostics
1085
1179
  WHERE collab_id = ?${filterClause}
1086
1180
  ORDER BY created_at DESC`).all(collabId, ...filterArgs);
1087
- return rows2.map(rowToRecord);
1181
+ return rows2.map(rowToRecord2);
1088
1182
  }
1089
1183
  const rows = db.prepare(`SELECT * FROM relay_capture_diagnostics
1090
1184
  WHERE collab_id = ?${filterClause}
1091
1185
  ORDER BY created_at DESC
1092
1186
  LIMIT ?`).all(collabId, ...filterArgs, limit);
1093
- return rows.map(rowToRecord);
1187
+ return rows.map(rowToRecord2);
1094
1188
  }
1095
1189
  function listCaptureDiagnosticsByCollabAndChain(db, collabId, chainId, limit) {
1096
1190
  if (limit === null) {
1097
1191
  const rows2 = db.prepare(`SELECT * FROM relay_capture_diagnostics
1098
1192
  WHERE collab_id = ? AND chain_id = ?
1099
1193
  ORDER BY created_at DESC`).all(collabId, chainId);
1100
- return rows2.map(rowToRecord);
1194
+ return rows2.map(rowToRecord2);
1101
1195
  }
1102
1196
  const rows = db.prepare(`SELECT * FROM relay_capture_diagnostics
1103
1197
  WHERE collab_id = ? AND chain_id = ?
1104
1198
  ORDER BY created_at DESC
1105
1199
  LIMIT ?`).all(collabId, chainId, limit);
1106
- return rows.map(rowToRecord);
1200
+ return rows.map(rowToRecord2);
1107
1201
  }
1108
1202
  function listCaptureDiagnosticsByHandoff(db, handoffId) {
1109
1203
  const rows = db.prepare(`SELECT * FROM relay_capture_diagnostics
1110
1204
  WHERE handoff_id = ?
1111
1205
  ORDER BY created_at ASC`).all(handoffId);
1112
- return rows.map(rowToRecord);
1206
+ return rows.map(rowToRecord2);
1113
1207
  }
1114
1208
  function deleteCaptureDiagnosticsOlderThan(db, cutoffIso) {
1115
1209
  const result = db.prepare("DELETE FROM relay_capture_diagnostics WHERE created_at < ?").run(cutoffIso);
@@ -1117,7 +1211,7 @@ function deleteCaptureDiagnosticsOlderThan(db, cutoffIso) {
1117
1211
  }
1118
1212
 
1119
1213
  // ../broker/dist/storage/repositories/relay-evaluator-diagnostics-repository.js
1120
- function rowToRecord2(row) {
1214
+ function rowToRecord3(row) {
1121
1215
  return {
1122
1216
  evaluatorId: row.evaluator_id,
1123
1217
  handoffId: row.handoff_id,
@@ -1162,32 +1256,32 @@ function listEvaluatorDiagnosticsByCollab(db, collabId, limit, opts) {
1162
1256
  const rows2 = db.prepare(`SELECT * FROM relay_evaluator_diagnostics
1163
1257
  WHERE collab_id = ?${filterClause}
1164
1258
  ORDER BY created_at DESC`).all(collabId, ...filterArgs);
1165
- return rows2.map(rowToRecord2);
1259
+ return rows2.map(rowToRecord3);
1166
1260
  }
1167
1261
  const rows = db.prepare(`SELECT * FROM relay_evaluator_diagnostics
1168
1262
  WHERE collab_id = ?${filterClause}
1169
1263
  ORDER BY created_at DESC
1170
1264
  LIMIT ?`).all(collabId, ...filterArgs, limit);
1171
- return rows.map(rowToRecord2);
1265
+ return rows.map(rowToRecord3);
1172
1266
  }
1173
1267
  function listEvaluatorDiagnosticsByCollabAndChain(db, collabId, chainId, limit) {
1174
1268
  if (limit === null) {
1175
1269
  const rows2 = db.prepare(`SELECT * FROM relay_evaluator_diagnostics
1176
1270
  WHERE collab_id = ? AND chain_id = ?
1177
1271
  ORDER BY created_at DESC`).all(collabId, chainId);
1178
- return rows2.map(rowToRecord2);
1272
+ return rows2.map(rowToRecord3);
1179
1273
  }
1180
1274
  const rows = db.prepare(`SELECT * FROM relay_evaluator_diagnostics
1181
1275
  WHERE collab_id = ? AND chain_id = ?
1182
1276
  ORDER BY created_at DESC
1183
1277
  LIMIT ?`).all(collabId, chainId, limit);
1184
- return rows.map(rowToRecord2);
1278
+ return rows.map(rowToRecord3);
1185
1279
  }
1186
1280
  function listEvaluatorDiagnosticsByHandoff(db, handoffId) {
1187
1281
  const rows = db.prepare(`SELECT * FROM relay_evaluator_diagnostics
1188
1282
  WHERE handoff_id = ?
1189
1283
  ORDER BY created_at ASC`).all(handoffId);
1190
- return rows.map(rowToRecord2);
1284
+ return rows.map(rowToRecord3);
1191
1285
  }
1192
1286
  function deleteEvaluatorDiagnosticsOlderThan(db, cutoffIso) {
1193
1287
  const result = db.prepare("DELETE FROM relay_evaluator_diagnostics WHERE created_at < ?").run(cutoffIso);
@@ -1195,7 +1289,7 @@ function deleteEvaluatorDiagnosticsOlderThan(db, cutoffIso) {
1195
1289
  }
1196
1290
 
1197
1291
  // ../broker/dist/storage/repositories/relay-handoff-repository.js
1198
- function rowToRecord3(row) {
1292
+ function rowToRecord4(row) {
1199
1293
  return {
1200
1294
  handoffId: row.handoff_id,
1201
1295
  collabId: row.collab_id,
@@ -1232,7 +1326,7 @@ function queryRelayHandoff(db, handoffId) {
1232
1326
  FROM relay_handoff h
1233
1327
  JOIN collab c ON c.collab_id = h.collab_id
1234
1328
  WHERE h.handoff_id = ?`).get(handoffId);
1235
- return row ? rowToRecord3(row) : null;
1329
+ return row ? rowToRecord4(row) : null;
1236
1330
  }
1237
1331
  function createRelayHandoffTxn(db, input) {
1238
1332
  return db.transaction(() => {
@@ -1384,7 +1478,7 @@ function queryLatestHandedBackHandoff(db, collabId) {
1384
1478
  WHERE h.collab_id = ? AND h.status = 'handed_back'
1385
1479
  ORDER BY h.resolved_at DESC
1386
1480
  LIMIT 1`).get(collabId);
1387
- return row ? rowToRecord3(row) : null;
1481
+ return row ? rowToRecord4(row) : null;
1388
1482
  }
1389
1483
  function failRelayHandoffOnDisconnectTxn(db, input) {
1390
1484
  db.transaction(() => {
@@ -1408,6 +1502,30 @@ function failRelayHandoffOnDisconnectTxn(db, input) {
1408
1502
  }
1409
1503
  })();
1410
1504
  }
1505
+ function hasInFlightAcceptedHandoffForWorkflow(db, workflowId) {
1506
+ const row = db.prepare("SELECT COUNT(*) AS n FROM relay_handoff WHERE workflow_id = ? AND status = 'accepted'").get(workflowId);
1507
+ return row.n > 0;
1508
+ }
1509
+ function resetStrandedOrchestrationForWorkflow(db, workflowId) {
1510
+ const result = db.prepare(`UPDATE relay_handoff
1511
+ SET orchestrator_status = 'idle', orchestrator_claimed_at = NULL
1512
+ WHERE workflow_id = ?
1513
+ AND status = 'handed_back'
1514
+ AND orchestrator_status = 'pending'
1515
+ AND evaluator_verdict IS NULL`).run(workflowId);
1516
+ return result.changes;
1517
+ }
1518
+ function prependToPendingHandoffRequestText(db, input) {
1519
+ const row = db.prepare(`SELECT handoff_id, request_text FROM relay_handoff
1520
+ WHERE workflow_id = ? AND status = 'pending'
1521
+ ORDER BY created_at DESC LIMIT 1`).get(input.workflowId);
1522
+ if (!row)
1523
+ return false;
1524
+ db.prepare("UPDATE relay_handoff SET request_text = ?, last_activity_at = ? WHERE handoff_id = ?").run(`${input.prefix}
1525
+
1526
+ ${row.request_text}`, input.now, row.handoff_id);
1527
+ return true;
1528
+ }
1411
1529
  function claimRelayHandoffForOrchestrationTxn(db, input) {
1412
1530
  const result = db.prepare(`UPDATE relay_handoff
1413
1531
  SET orchestrator_status = 'pending',
@@ -1426,10 +1544,12 @@ function listRelayHandoffsPendingOrchestration(db, collabId) {
1426
1544
  c.orchestrator_max_rounds AS max_rounds
1427
1545
  FROM relay_handoff h
1428
1546
  JOIN collab c ON c.collab_id = h.collab_id
1547
+ LEFT JOIN workflows w ON w.workflow_id = h.workflow_id
1429
1548
  WHERE h.collab_id = ?
1430
1549
  AND h.status = 'handed_back'
1431
- AND h.orchestrator_status = 'idle'`).all(collabId);
1432
- return rows.map(rowToRecord3);
1550
+ AND h.orchestrator_status = 'idle'
1551
+ AND (w.status IS NULL OR w.status <> 'paused')`).all(collabId);
1552
+ return rows.map(rowToRecord4);
1433
1553
  }
1434
1554
  function createLoopRelayHandoffTxn(db, input) {
1435
1555
  return db.transaction(() => {
@@ -1625,7 +1745,7 @@ function getHandoffWithWorkflowMetaById(db, handoffId) {
1625
1745
  WHERE h.handoff_id = ?`).get(handoffId);
1626
1746
  if (!row)
1627
1747
  return null;
1628
- const base = rowToRecord3(row);
1748
+ const base = rowToRecord4(row);
1629
1749
  return {
1630
1750
  ...base,
1631
1751
  handoffStep: row.handoff_step,
@@ -1875,71 +1995,6 @@ function listRunCostRows(db, input) {
1875
1995
  // ../broker/dist/control/workflow-control.js
1876
1996
  import { randomUUID } from "node:crypto";
1877
1997
 
1878
- // ../broker/dist/storage/repositories/workflow-repository.js
1879
- function rowToRecord4(row) {
1880
- return {
1881
- workflowId: row.workflow_id,
1882
- collabId: row.collab_id,
1883
- workflowType: row.workflow_type,
1884
- name: row.name,
1885
- specPath: row.spec_path,
1886
- roleBindings: JSON.parse(row.role_bindings),
1887
- status: row.status,
1888
- currentPhaseIndex: row.current_phase_index,
1889
- haltReason: row.halt_reason,
1890
- workflowContext: JSON.parse(row.workflow_context),
1891
- createdAt: row.created_at,
1892
- updatedAt: row.updated_at
1893
- };
1894
- }
1895
- function insertWorkflow(db, input) {
1896
- db.prepare(`INSERT INTO workflows
1897
- (workflow_id, collab_id, workflow_type, name, spec_path, role_bindings, status,
1898
- current_phase_index, halt_reason, workflow_context, created_at, updated_at)
1899
- 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);
1900
- }
1901
- function getWorkflowById(db, workflowId) {
1902
- const row = db.prepare("SELECT * FROM workflows WHERE workflow_id = ?").get(workflowId);
1903
- return row ? rowToRecord4(row) : null;
1904
- }
1905
- function listWorkflows(db, filter = {}) {
1906
- const clauses = [];
1907
- const args = [];
1908
- if (filter.collabId) {
1909
- clauses.push("collab_id = ?");
1910
- args.push(filter.collabId);
1911
- }
1912
- if (filter.status) {
1913
- clauses.push("status = ?");
1914
- args.push(filter.status);
1915
- }
1916
- const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
1917
- const rows = db.prepare(`SELECT * FROM workflows ${where} ORDER BY created_at DESC`).all(...args);
1918
- return rows.map(rowToRecord4);
1919
- }
1920
- function setWorkflowStatus(db, input) {
1921
- db.prepare(`UPDATE workflows
1922
- SET status = ?, halt_reason = ?, updated_at = ?
1923
- WHERE workflow_id = ?`).run(input.status, input.haltReason, input.now, input.workflowId);
1924
- }
1925
- function updateWorkflowContext(db, input) {
1926
- const existing = getWorkflowById(db, input.workflowId);
1927
- if (!existing) {
1928
- throw new Error(`updateWorkflowContext: unknown workflowId ${input.workflowId}`);
1929
- }
1930
- const merged = { ...existing.workflowContext, ...input.patch };
1931
- db.prepare("UPDATE workflows SET workflow_context = ?, updated_at = ? WHERE workflow_id = ?").run(JSON.stringify(merged), input.now, input.workflowId);
1932
- }
1933
- function incrementCurrentPhaseIndex(db, input) {
1934
- db.prepare(`UPDATE workflows
1935
- SET current_phase_index = current_phase_index + 1, updated_at = ?
1936
- WHERE workflow_id = ?`).run(input.now, input.workflowId);
1937
- }
1938
- function countRunningWorkflowsForCollab(db, collabId) {
1939
- const row = db.prepare("SELECT COUNT(*) AS n FROM workflows WHERE collab_id = ? AND status = 'running'").get(collabId);
1940
- return row.n;
1941
- }
1942
-
1943
1998
  // ../broker/dist/storage/repositories/workflow-phase-repository.js
1944
1999
  function rowToRecord5(row) {
1945
2000
  return {
@@ -2060,6 +2115,14 @@ Findings: (omit this block entirely if none)
2060
2115
  Non-blocking risks:
2061
2116
  - <quality risk that does NOT block this gate, or "None.">
2062
2117
  --- end protocol ---`;
2118
+ var WORKFLOW_OPERATOR_CONTROL = `--- ai-whisper operator control ---
2119
+ If the operator interrupts you and asks to pause the workflow (e.g. "pause the workflow, I need to fix X"):
2120
+ 1. Find the active workflow id: run \`whisper workflow list\`.
2121
+ 2. Run \`whisper workflow pause <id>\`.
2122
+ 3. Acknowledge and STOP working \u2014 do not start the next change.
2123
+ 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.
2124
+ 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.
2125
+ --- end operator control ---`;
2063
2126
  var WORKFLOW_DIAGNOSIS_PROTOCOL = `--- ai-whisper diagnosis review protocol ---
2064
2127
  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.
2065
2128
 
@@ -2298,10 +2361,21 @@ var COMPLEX_BUG_FIXING = {
2298
2361
  }
2299
2362
  ]
2300
2363
  };
2364
+ function withOperatorControl(def) {
2365
+ return {
2366
+ ...def,
2367
+ phases: def.phases.map((p) => ({
2368
+ ...p,
2369
+ kickoffTemplate: `${p.kickoffTemplate}
2370
+
2371
+ ${WORKFLOW_OPERATOR_CONTROL}`
2372
+ }))
2373
+ };
2374
+ }
2301
2375
  var REGISTRY = {
2302
- [SPEC_DRIVEN_DEVELOPMENT.type]: SPEC_DRIVEN_DEVELOPMENT,
2303
- [RALPH_LOOP.type]: RALPH_LOOP,
2304
- [COMPLEX_BUG_FIXING.type]: COMPLEX_BUG_FIXING
2376
+ [SPEC_DRIVEN_DEVELOPMENT.type]: withOperatorControl(SPEC_DRIVEN_DEVELOPMENT),
2377
+ [RALPH_LOOP.type]: withOperatorControl(RALPH_LOOP),
2378
+ [COMPLEX_BUG_FIXING.type]: withOperatorControl(COMPLEX_BUG_FIXING)
2305
2379
  };
2306
2380
  function ralphRunDir(workspaceRoot, workflowId) {
2307
2381
  return join(workspaceRoot, ".ai-whisper", "ralph", workflowId);
@@ -2357,6 +2431,23 @@ function safeDerivePlanPath(specPath, createdAt) {
2357
2431
  return `${dir}${stem}.plan.md`;
2358
2432
  }
2359
2433
  }
2434
+ function composeResumeNotice(input) {
2435
+ const hasFiles = input.changedFiles.length > 0;
2436
+ const hasMessage = !!input.message && input.message.trim().length > 0;
2437
+ if (!hasFiles && !hasMessage)
2438
+ return null;
2439
+ const lines = [];
2440
+ if (hasFiles) {
2441
+ lines.push("While paused, the operator modified these files:");
2442
+ for (const f of input.changedFiles)
2443
+ lines.push(` - ${f}`);
2444
+ lines.push("Re-read them before continuing.");
2445
+ }
2446
+ if (hasMessage)
2447
+ lines.push(`Operator note: ${input.message.trim()}`);
2448
+ lines.push("Re-evaluate whether your current direction still holds; correct course before proceeding.");
2449
+ return lines.join("\n");
2450
+ }
2360
2451
  function createWorkflowControl(deps) {
2361
2452
  const { db, events } = deps;
2362
2453
  function createWorkflow(input) {
@@ -2379,8 +2470,9 @@ function createWorkflowControl(deps) {
2379
2470
  }
2380
2471
  const workflowId = `wf_${randomUUID().replace(/-/g, "").slice(0, 16)}`;
2381
2472
  const tx = db.transaction(() => {
2382
- if (countRunningWorkflowsForCollab(db, input.collabId) > 0) {
2383
- throw new Error(`another workflow is already running on this collab (${input.collabId})`);
2473
+ if (countActiveWorkflowsForCollab(db, input.collabId) > 0) {
2474
+ const active = listWorkflows(db, { collabId: input.collabId }).find((w) => w.status === "running" || w.status === "paused");
2475
+ throw new Error(`another workflow is already active on this collab (${input.collabId})` + (active ? `: ${active.workflowId} (${active.status})` : ""));
2384
2476
  }
2385
2477
  insertWorkflow(db, {
2386
2478
  workflowId,
@@ -2548,6 +2640,24 @@ function createWorkflowControl(deps) {
2548
2640
  postmortemPath: bf.postmortemPath
2549
2641
  });
2550
2642
  }
2643
+ function consumeResumeNotice(input) {
2644
+ const wf = getWorkflowById(db, input.workflowId);
2645
+ const notice = wf?.workflowContext?.resumeNotice ?? null;
2646
+ if (notice) {
2647
+ updateWorkflowContext(db, {
2648
+ workflowId: input.workflowId,
2649
+ patch: { resumeNotice: null },
2650
+ now: input.now
2651
+ });
2652
+ }
2653
+ return notice;
2654
+ }
2655
+ function withResumeNotice(workflowId, requestText, now) {
2656
+ const notice = consumeResumeNotice({ workflowId, now });
2657
+ return notice ? `${notice}
2658
+
2659
+ ${requestText}` : requestText;
2660
+ }
2551
2661
  function createContinuationHandoff(input) {
2552
2662
  const handoffId = `ho_${randomUUID().replace(/-/g, "").slice(0, 16)}`;
2553
2663
  insertWorkflowOwnedRelayHandoff(db, {
@@ -2555,7 +2665,9 @@ function createWorkflowControl(deps) {
2555
2665
  collabId: input.workflow.collabId,
2556
2666
  senderAgent: input.sender,
2557
2667
  targetAgent: input.target,
2558
- requestText: input.requestText,
2668
+ // Prepend the one-time resume notice (if any) to the FIRST outgoing request
2669
+ // after a resume, then it is cleared — so the agent re-reads changed files.
2670
+ requestText: withResumeNotice(input.workflow.workflowId, input.requestText, input.now),
2559
2671
  chainId: input.chain.chainId,
2560
2672
  roundNumber: input.incrementRound ? input.chain.currentRound + 1 : input.chain.currentRound,
2561
2673
  maxRounds: input.chain.maxRounds,
@@ -2615,7 +2727,9 @@ function createWorkflowControl(deps) {
2615
2727
  collabId: input.workflow.collabId,
2616
2728
  senderAgent: sender,
2617
2729
  targetAgent: target,
2618
- requestText: kickoffText,
2730
+ // Prepend a pending resume notice (consumed once) so a phase-advance kickoff
2731
+ // after a resume also carries the operator's change notice.
2732
+ requestText: withResumeNotice(input.workflow.workflowId, kickoffText, input.now),
2619
2733
  chainId,
2620
2734
  roundNumber: 1,
2621
2735
  maxRounds: phase.maxRounds,
@@ -3076,20 +3190,100 @@ ${findingsText}`;
3076
3190
  reason: input.reason
3077
3191
  });
3078
3192
  }
3193
+ function maybeCaptureQuiesceSnapshot(input) {
3194
+ const wf = getWorkflowById(db, input.workflowId);
3195
+ if (!wf || wf.status !== "paused")
3196
+ return;
3197
+ if (wf.workflowContext.pauseSnapshotRef !== void 0) {
3198
+ return;
3199
+ }
3200
+ if (hasInFlightAcceptedHandoffForWorkflow(db, input.workflowId))
3201
+ return;
3202
+ const ref = deps.captureSnapshotRef ? deps.captureSnapshotRef(input.workflowId) : null;
3203
+ updateWorkflowContext(db, {
3204
+ workflowId: input.workflowId,
3205
+ patch: { pauseSnapshotRef: ref },
3206
+ now: input.now
3207
+ });
3208
+ }
3209
+ function pauseWorkflow(input) {
3210
+ const workflow = getWorkflowById(db, input.workflowId);
3211
+ if (!workflow) {
3212
+ throw new Error(`pauseWorkflow: unknown workflowId ${input.workflowId}`);
3213
+ }
3214
+ if (workflow.status !== "running") {
3215
+ throw new Error(`pauseWorkflow: workflow ${input.workflowId} is ${workflow.status}, only running workflows can be paused`);
3216
+ }
3217
+ const tx = db.transaction(() => {
3218
+ const current = getWorkflowById(db, input.workflowId);
3219
+ if (!current || current.status !== "running")
3220
+ return;
3221
+ setWorkflowStatus(db, {
3222
+ workflowId: input.workflowId,
3223
+ status: "paused",
3224
+ haltReason: null,
3225
+ now: input.now
3226
+ });
3227
+ updateWorkflowContext(db, {
3228
+ workflowId: input.workflowId,
3229
+ patch: { pausedAt: input.now },
3230
+ now: input.now
3231
+ });
3232
+ });
3233
+ tx.immediate();
3234
+ events.emit("workflow.paused", { workflowId: input.workflowId });
3235
+ maybeCaptureQuiesceSnapshot({ workflowId: input.workflowId, now: input.now });
3236
+ }
3237
+ function resumeHaltedWorkflow(workflow, now) {
3238
+ const tx = db.transaction(() => {
3239
+ const others = listWorkflows(db, { collabId: workflow.collabId }).filter((w) => w.workflowId !== workflow.workflowId && (w.status === "running" || w.status === "paused"));
3240
+ if (others.length > 0) {
3241
+ throw new Error(`resumeWorkflow: another workflow is already active on collab ${workflow.collabId}`);
3242
+ }
3243
+ setWorkflowStatus(db, {
3244
+ workflowId: workflow.workflowId,
3245
+ status: "running",
3246
+ haltReason: null,
3247
+ now
3248
+ });
3249
+ });
3250
+ tx.immediate();
3251
+ events.emit("workflow.resumed", {
3252
+ workflowId: workflow.workflowId,
3253
+ phaseIndex: workflow.currentPhaseIndex
3254
+ });
3255
+ }
3079
3256
  function resumeWorkflow(input) {
3080
3257
  const workflow = getWorkflowById(db, input.workflowId);
3081
3258
  if (!workflow) {
3082
3259
  throw new Error(`resumeWorkflow: unknown workflowId ${input.workflowId}`);
3083
3260
  }
3084
- if (workflow.status === "canceled") {
3085
- throw new Error(`resumeWorkflow: workflow ${input.workflowId} is canceled and cannot be resumed`);
3261
+ if (workflow.status === "halted") {
3262
+ resumeHaltedWorkflow(workflow, input.now);
3263
+ return;
3086
3264
  }
3087
- if (workflow.status !== "halted") {
3088
- throw new Error(`resumeWorkflow: workflow ${input.workflowId} is not halted (status=${workflow.status})`);
3265
+ if (workflow.status !== "paused") {
3266
+ throw new Error(`resumeWorkflow: workflow ${input.workflowId} is ${workflow.status}, only paused or halted workflows can be resumed`);
3089
3267
  }
3268
+ const ref = workflow.workflowContext.pauseSnapshotRef ?? null;
3269
+ const changedFiles = ref && deps.diffChangedFilesSinceSnapshot ? deps.diffChangedFilesSinceSnapshot(input.workflowId, ref) : [];
3270
+ const notice = composeResumeNotice({
3271
+ changedFiles,
3272
+ message: input.message ?? null
3273
+ });
3090
3274
  const tx = db.transaction(() => {
3091
- if (countRunningWorkflowsForCollab(db, workflow.collabId) > 0) {
3092
- throw new Error(`resumeWorkflow: another workflow is already running on collab ${workflow.collabId}`);
3275
+ const others = listWorkflows(db, { collabId: workflow.collabId }).filter((w) => w.workflowId !== input.workflowId && (w.status === "running" || w.status === "paused"));
3276
+ if (others.length > 0) {
3277
+ throw new Error(`resumeWorkflow: another workflow is already active on collab ${workflow.collabId}`);
3278
+ }
3279
+ resetStrandedOrchestrationForWorkflow(db, input.workflowId);
3280
+ let bakedIntoPending = false;
3281
+ if (notice) {
3282
+ bakedIntoPending = prependToPendingHandoffRequestText(db, {
3283
+ workflowId: input.workflowId,
3284
+ prefix: notice,
3285
+ now: input.now
3286
+ });
3093
3287
  }
3094
3288
  setWorkflowStatus(db, {
3095
3289
  workflowId: input.workflowId,
@@ -3097,6 +3291,15 @@ ${findingsText}`;
3097
3291
  haltReason: null,
3098
3292
  now: input.now
3099
3293
  });
3294
+ updateWorkflowContext(db, {
3295
+ workflowId: input.workflowId,
3296
+ patch: {
3297
+ resumeNotice: bakedIntoPending ? null : notice,
3298
+ pausedAt: null,
3299
+ pauseSnapshotRef: null
3300
+ },
3301
+ now: input.now
3302
+ });
3100
3303
  });
3101
3304
  tx.immediate();
3102
3305
  events.emit("workflow.resumed", {
@@ -3197,6 +3400,9 @@ ${findingsText}`;
3197
3400
  beginPhaseRun,
3198
3401
  applyOrchestratorVerdict,
3199
3402
  haltWorkflow,
3403
+ pauseWorkflow,
3404
+ maybeCaptureQuiesceSnapshot,
3405
+ consumeResumeNotice,
3200
3406
  resumeWorkflow,
3201
3407
  cancelWorkflow,
3202
3408
  getHandoffWithWorkflowMeta,
@@ -3212,8 +3418,32 @@ function buildEventId(kind, subjectId, timestamp) {
3212
3418
  return `evt_${kind}_${subjectId}_${normalizeTimestampForEventId(timestamp)}`;
3213
3419
  }
3214
3420
  function createControlService(db, events) {
3215
- const workflowControl = createWorkflowControl({ db, events });
3421
+ const resolveWorkspaceRoot = (workflowId) => {
3422
+ const wf = getWorkflowById(db, workflowId);
3423
+ if (!wf)
3424
+ return null;
3425
+ return getCollab(db, wf.collabId)?.workspaceRoot ?? null;
3426
+ };
3427
+ const workflowControl = createWorkflowControl({
3428
+ db,
3429
+ events,
3430
+ captureSnapshotRef: (workflowId) => {
3431
+ const root = resolveWorkspaceRoot(workflowId);
3432
+ return root ? captureWorkspaceSnapshotSync(root) : null;
3433
+ },
3434
+ diffChangedFilesSinceSnapshot: (workflowId, sinceRef) => {
3435
+ const root = resolveWorkspaceRoot(workflowId);
3436
+ return root ? diffChangedFilesSince(root, sinceRef) : [];
3437
+ }
3438
+ });
3439
+ const isWorkflowDeliverySuspended = (handoffId) => {
3440
+ const meta = workflowControl.getHandoffWithWorkflowMeta(handoffId);
3441
+ if (!meta?.workflowId)
3442
+ return false;
3443
+ return workflowControl.getWorkflow(meta.workflowId)?.status === "paused";
3444
+ };
3216
3445
  return Object.assign({
3446
+ isWorkflowDeliverySuspended,
3217
3447
  startCollab(input) {
3218
3448
  const collab = collabSchema.parse({
3219
3449
  version: 1,
@@ -3841,6 +4071,8 @@ function createControlService(db, events) {
3841
4071
  return createRelayHandoffTxn(db, input);
3842
4072
  },
3843
4073
  acceptRelayHandoff(input) {
4074
+ if (isWorkflowDeliverySuspended(input.handoffId))
4075
+ return;
3844
4076
  return acceptRelayHandoffTxn(db, input);
3845
4077
  },
3846
4078
  deferRelayHandoff(input) {
@@ -3853,7 +4085,12 @@ function createControlService(db, events) {
3853
4085
  return markRelayHandoffStaleTxn(db, input);
3854
4086
  },
3855
4087
  handoffBackRelay(input) {
3856
- return handoffBackRelayTxn(db, input);
4088
+ const result = handoffBackRelayTxn(db, input);
4089
+ const meta = workflowControl.getHandoffWithWorkflowMeta(input.handoffId);
4090
+ if (meta?.workflowId && workflowControl.getWorkflow(meta.workflowId)?.status === "paused") {
4091
+ workflowControl.maybeCaptureQuiesceSnapshot({ workflowId: meta.workflowId, now: input.now });
4092
+ }
4093
+ return result;
3857
4094
  },
3858
4095
  failRelayHandoffOnDisconnect(input) {
3859
4096
  return failRelayHandoffOnDisconnectTxn(db, input);
@@ -3865,6 +4102,8 @@ function createControlService(db, events) {
3865
4102
  return queryLatestHandedBackHandoff(db, collabId);
3866
4103
  },
3867
4104
  claimRelayHandoffForOrchestration(input) {
4105
+ if (isWorkflowDeliverySuspended(input.handoffId))
4106
+ return null;
3868
4107
  return claimRelayHandoffForOrchestrationTxn(db, input);
3869
4108
  },
3870
4109
  listRelayHandoffsPendingOrchestration(collabId) {
@@ -4318,8 +4557,9 @@ CREATE TABLE IF NOT EXISTS workflows (
4318
4557
  updated_at TEXT NOT NULL
4319
4558
  );
4320
4559
 
4560
+ DROP INDEX IF EXISTS workflows_one_running_per_collab;
4321
4561
  CREATE UNIQUE INDEX IF NOT EXISTS workflows_one_running_per_collab
4322
- ON workflows(collab_id) WHERE status = 'running';
4562
+ ON workflows(collab_id) WHERE status IN ('running', 'paused');
4323
4563
 
4324
4564
  CREATE TABLE IF NOT EXISTS workflow_phases (
4325
4565
  phase_run_id TEXT PRIMARY KEY,