clinkx 1.1.2 → 1.2.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 (56) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +63 -12
  3. package/clinkx-workflows/dist/allowed-roots.js +2 -0
  4. package/clinkx-workflows/dist/allowed-roots.js.map +1 -1
  5. package/clinkx-workflows/dist/child-env.d.ts +3 -1
  6. package/clinkx-workflows/dist/child-env.js +6 -2
  7. package/clinkx-workflows/dist/child-env.js.map +1 -1
  8. package/clinkx-workflows/dist/config.d.ts +28 -5
  9. package/clinkx-workflows/dist/config.js +137 -20
  10. package/clinkx-workflows/dist/config.js.map +1 -1
  11. package/clinkx-workflows/dist/contained-file.js +9 -0
  12. package/clinkx-workflows/dist/contained-file.js.map +1 -1
  13. package/clinkx-workflows/dist/dashboard/session-log-identity.js +2 -1
  14. package/clinkx-workflows/dist/dashboard/session-log-identity.js.map +1 -1
  15. package/clinkx-workflows/dist/root-path-validation.d.ts +12 -0
  16. package/clinkx-workflows/dist/root-path-validation.js +63 -0
  17. package/clinkx-workflows/dist/root-path-validation.js.map +1 -0
  18. package/clinkx-workflows/dist/rosters.d.ts +1 -0
  19. package/clinkx-workflows/dist/rosters.js +16 -6
  20. package/clinkx-workflows/dist/rosters.js.map +1 -1
  21. package/clinkx-workflows/dist/server.js +5 -2
  22. package/clinkx-workflows/dist/server.js.map +1 -1
  23. package/clinkx-workflows/dist/state-schema.d.ts +13 -0
  24. package/clinkx-workflows/dist/state.d.ts +7 -1
  25. package/clinkx-workflows/dist/state.js +74 -13
  26. package/clinkx-workflows/dist/state.js.map +1 -1
  27. package/clinkx-workflows/dist/workflow-completion.d.ts +83 -0
  28. package/clinkx-workflows/dist/workflow-completion.js +250 -0
  29. package/clinkx-workflows/dist/workflow-completion.js.map +1 -0
  30. package/clinkx-workflows/dist/workflow-tools.d.ts +4 -3
  31. package/clinkx-workflows/dist/workflow-tools.js +341 -34
  32. package/clinkx-workflows/dist/workflow-tools.js.map +1 -1
  33. package/dist/allowed-roots.d.ts +1 -1
  34. package/dist/allowed-roots.js +124 -14
  35. package/dist/allowed-roots.js.map +1 -1
  36. package/dist/context-builder/config.d.ts +1 -0
  37. package/dist/context-builder/config.js +21 -9
  38. package/dist/context-builder/config.js.map +1 -1
  39. package/dist/context-builder/mutation-lock.d.ts +4 -0
  40. package/dist/context-builder/mutation-lock.js +7 -1
  41. package/dist/context-builder/mutation-lock.js.map +1 -1
  42. package/dist/context-builder/storage.d.ts +10 -2
  43. package/dist/context-builder/storage.js +213 -16
  44. package/dist/context-builder/storage.js.map +1 -1
  45. package/dist/dashboard-cli.d.ts +6 -0
  46. package/dist/dashboard-cli.js +8 -29
  47. package/dist/dashboard-cli.js.map +1 -1
  48. package/dist/registry.d.ts +1 -1
  49. package/dist/registry.js +5 -1
  50. package/dist/registry.js.map +1 -1
  51. package/dist/server.js +12 -5
  52. package/dist/server.js.map +1 -1
  53. package/docs/configuration.md +157 -12
  54. package/docs/security.md +3 -0
  55. package/package.json +1 -1
  56. package/scripts/provider-doctor-core.mjs +21 -5
@@ -5,11 +5,12 @@
5
5
  * - Standalone STDIO server (ClinkSession child process backend)
6
6
  * - In-process integration (LocalClinkBackend via root ClinkX)
7
7
  *
8
- * The `registerWorkflowTools()` function registers five tools on a Server:
8
+ * The `registerWorkflowTools()` function registers six tools on a Server:
9
9
  * - run_workflow: start a new workflow execution
10
10
  * - resume_workflow: resume a failed/cancelled run
11
11
  * - list_workflows: list available workflow definitions
12
12
  * - workflow_run_status: read workflow run progress/result
13
+ * - workflow_run_wait: observe local execution settlement
13
14
  * - cancel_workflow: cancel a live or orphaned workflow run
14
15
  *
15
16
  * The caller provides a `createBackend` factory that determines how clink
@@ -51,7 +52,7 @@ import { restorePlanReviewRun, } from "./plan-review/resume.js";
51
52
  import { PlanReviewStageError } from "./plan-review/stage-error.js";
52
53
  import { CouncilPlanReviewErrorSchema, createPlanReviewPublicationError, inspectPlanReviewPublication, } from "./plan-review/publication.js";
53
54
  import { validatePlanReviewCallBindings, validatePlanReviewPreparedInputPolicy, } from "./plan-review/runtime-policy.js";
54
- import { ContainedFilePolicyError, readContainedRelativeFile, } from "./contained-file.js";
55
+ import { ContainedFileChangedError, ContainedFilePolicyError, readContainedRelativeFile, } from "./contained-file.js";
55
56
  import { createDashboardRunReader } from "./dashboard/read-model.js";
56
57
  import { ROSTER_NAME_PATTERN, ResolvedRosterSchema, } from "./roster-contract.js";
57
58
  import { materializeArchitectureRoutes } from "./architecture-comparison/admission.js";
@@ -62,6 +63,8 @@ import { materializeVerifiedRepairRoutes, repairRoutesFromVariables } from "./ve
62
63
  import { VerifiedRepairRuntime } from "./verified-repair/runtime.js";
63
64
  import { inspectVerifiedRepairRecord } from "./verified-repair/result.js";
64
65
  import { readWorkflowVerification } from "./workflow-verification.js";
66
+ import { readSessionLogGeneration } from "./dashboard/session-log-identity.js";
67
+ import { WorkflowCompletionRegistry, inspectPublication, persistedIdentityFailure, WORKFLOW_WAIT_BUDGET_MS, } from "./workflow-completion.js";
65
68
  async function inspectLegacyWorkflowSessionLogGeneration(runDir, runId) {
66
69
  try {
67
70
  const result = await createDashboardRunReader().inspectGeneration?.(dirname(runDir), runId);
@@ -104,6 +107,7 @@ function createPreflightOnlyBackend(advertisedCliNames) {
104
107
  // Admission control
105
108
  // ---------------------------------------------------------------------------
106
109
  const activeRuns = new Map();
110
+ const completions = new WorkflowCompletionRegistry();
107
111
  /**
108
112
  * Number of workflow tool handlers currently executing in the standalone
109
113
  * registration path. This includes non-run tools such as status/list/cancel;
@@ -173,6 +177,9 @@ function releaseMutex(runId) {
173
177
  /** Own setup before its first await, and keep that ownership through cleanup. */
174
178
  function acquireWorkflowRun(runId, options, requestSignal) {
175
179
  acquireMutex(runId, options);
180
+ // Bound lazily: a request rejected before it owns state never becomes an
181
+ // execution, so it cannot displace a settled one or consume a retained slot.
182
+ const owner = completions.owner(runId, getRunStateDir(runId));
176
183
  const controller = new AbortController();
177
184
  const signal = requestSignal === undefined
178
185
  ? controller.signal
@@ -201,6 +208,15 @@ function acquireWorkflowRun(runId, options, requestSignal) {
201
208
  if (requestSignal === undefined)
202
209
  cancelRegistry.set(runId, controller);
203
210
  return {
211
+ get executionId() { return owner.entry?.executionId; },
212
+ bindStore: async (store) => {
213
+ // Register first, so a pin on the previous execution already fails as
214
+ // identity_changed while this execution's id is being persisted.
215
+ const entry = owner.bind(store);
216
+ await store.bindExecution(entry.executionId);
217
+ completions.markStamped(entry);
218
+ return entry.executionId;
219
+ },
204
220
  signal,
205
221
  throwIfCancelled: () => {
206
222
  if (signal.aborted)
@@ -215,6 +231,14 @@ function acquireWorkflowRun(runId, options, requestSignal) {
215
231
  catch (err) {
216
232
  logger.warn({ err }, "backend.terminate() failed during cleanup");
217
233
  }
234
+ try {
235
+ await store?.flush?.();
236
+ if (owner.entry !== undefined)
237
+ await completions.recordPublication(owner.entry);
238
+ }
239
+ catch (err) {
240
+ logger.warn({ err }, "workflow publication identity unavailable during cleanup");
241
+ }
218
242
  try {
219
243
  await store?.close();
220
244
  }
@@ -226,6 +250,8 @@ function acquireWorkflowRun(runId, options, requestSignal) {
226
250
  cancelRegistry.delete(runId);
227
251
  releaseMutex(runId);
228
252
  shutdownCoordinator.unregister(handle);
253
+ if (owner.entry !== undefined)
254
+ completions.settle(owner.entry);
229
255
  complete();
230
256
  }
231
257
  },
@@ -1124,18 +1150,22 @@ function appendDuplicateReplayBlock(content, store) {
1124
1150
  });
1125
1151
  }
1126
1152
  /**
1127
- * Trailing additive content block for the admission-time requested-value
1128
- * roster. Omitted when the run did not persist one (non-council, plan-review,
1129
- * or pre-roster state). Defensive optional access: partial store mocks may
1130
- * omit runState.
1153
+ * Trailing additive identity block, always last: the admission-time
1154
+ * requested-value roster (omitted for non-council, plan-review, or pre-roster
1155
+ * state) and the execution_id bound to this request (omitted when the request
1156
+ * was rejected before it owned state). No block when neither is present.
1157
+ * Defensive optional access: partial store mocks may omit runState.
1131
1158
  */
1132
- function appendResolvedRosterBlock(content, store) {
1159
+ function appendIdentityBlock(content, store, executionId) {
1133
1160
  const resolvedRoster = store?.runState?.resolvedRoster;
1134
- if (resolvedRoster === undefined)
1161
+ if (resolvedRoster === undefined && executionId === undefined)
1135
1162
  return;
1136
1163
  content.push({
1137
1164
  type: "text",
1138
- text: JSON.stringify({ resolved_roster: resolvedRoster }),
1165
+ text: JSON.stringify({
1166
+ ...(resolvedRoster !== undefined ? { resolved_roster: resolvedRoster } : {}),
1167
+ ...(executionId !== undefined ? { execution_id: executionId } : {}),
1168
+ }),
1139
1169
  });
1140
1170
  }
1141
1171
  // ---------------------------------------------------------------------------
@@ -1527,6 +1557,7 @@ async function executeRunWorkflow(server, options, args, signal, progressToken)
1527
1557
  let effectiveDefinition;
1528
1558
  let initializedPlanReview;
1529
1559
  let preparedSnapshot;
1560
+ let executionId;
1530
1561
  try {
1531
1562
  ownership.throwIfCancelled();
1532
1563
  const runDir = getRunStateDir(runId);
@@ -1559,6 +1590,7 @@ async function executeRunWorkflow(server, options, args, signal, progressToken)
1559
1590
  catch (err) {
1560
1591
  throw new McpError(ErrorCode.InternalError, `Failed to initialize async run ${runId}: ${err instanceof Error ? err.message : String(err)}`);
1561
1592
  }
1593
+ executionId = await ownership.bindStore(store);
1562
1594
  ownership.throwIfCancelled();
1563
1595
  // A-10: the async acknowledgement is a durability boundary. A caller
1564
1596
  // cannot observe `running` until every plan-review snapshot and the
@@ -1574,7 +1606,9 @@ async function executeRunWorkflow(server, options, args, signal, progressToken)
1574
1606
  await markWorkflowCancelled(store);
1575
1607
  throw new WorkflowCancellationError();
1576
1608
  }
1577
- if (store !== undefined && planReviewPlan !== undefined) {
1609
+ // Once bound (plan-review init, or a failed id stamp), the run owns a
1610
+ // persisted record: terminalize it and disclose the bound identity.
1611
+ if (store !== undefined && ownership.executionId !== undefined) {
1578
1612
  try {
1579
1613
  await markWorkflowFailed(store, runId, err);
1580
1614
  }
@@ -1601,6 +1635,8 @@ async function executeRunWorkflow(server, options, args, signal, progressToken)
1601
1635
  summary: failureSummary,
1602
1636
  debugBlock: debug ? buildErrorDebugBlock(runId, store) : undefined,
1603
1637
  });
1638
+ // Bound even if persisting the id then failed: the entry exists.
1639
+ appendIdentityBlock(content, store, ownership.executionId);
1604
1640
  return { content, isError: true };
1605
1641
  }
1606
1642
  throw err;
@@ -1619,7 +1655,7 @@ async function executeRunWorkflow(server, options, args, signal, progressToken)
1619
1655
  }
1620
1656
  }
1621
1657
  }
1622
- if (store === undefined || effectiveDefinition === undefined) {
1658
+ if (store === undefined || effectiveDefinition === undefined || executionId === undefined) {
1623
1659
  await ownership.finish(store);
1624
1660
  throw new McpError(ErrorCode.InternalError, `Failed to initialize async run ${runId}`);
1625
1661
  }
@@ -1640,6 +1676,7 @@ async function executeRunWorkflow(server, options, args, signal, progressToken)
1640
1676
  type: "text",
1641
1677
  text: JSON.stringify({
1642
1678
  run_id: runId,
1679
+ execution_id: executionId,
1643
1680
  status: "running",
1644
1681
  ...(duplicateOfFailedRun !== undefined
1645
1682
  ? { duplicate_of_failed_run: duplicateOfFailedRun }
@@ -1877,6 +1914,7 @@ async function runWorkflowDetached(server, options, workflow, runId, resolvedVar
1877
1914
  ...(rosterName !== undefined ? { rosterName } : {}),
1878
1915
  });
1879
1916
  }
1917
+ await ownership.bindStore(store);
1880
1918
  ownership.throwIfCancelled();
1881
1919
  releaseLifecycleBinding = options.contextBuilderLifecycleRouter?.bind(runId, store);
1882
1920
  // A-5/A-6: snapshot + contract + typed subtree land here — after state
@@ -1986,7 +2024,7 @@ async function runWorkflowDetached(server, options, workflow, runId, resolvedVar
1986
2024
  debugBlock: debug ? buildDebugBlock(runId, result, store) : undefined,
1987
2025
  });
1988
2026
  appendDuplicateReplayBlock(content, store);
1989
- appendResolvedRosterBlock(content, store);
2027
+ appendIdentityBlock(content, store, ownership.executionId);
1990
2028
  return { content };
1991
2029
  }
1992
2030
  catch (error) {
@@ -2039,7 +2077,7 @@ async function runWorkflowDetached(server, options, workflow, runId, resolvedVar
2039
2077
  debugBlock: debug && store != null ? buildErrorDebugBlock(runId, store) : undefined,
2040
2078
  });
2041
2079
  appendDuplicateReplayBlock(content, store);
2042
- appendResolvedRosterBlock(content, store);
2080
+ appendIdentityBlock(content, store, ownership.executionId);
2043
2081
  return { content, isError: true };
2044
2082
  }
2045
2083
  finally {
@@ -2053,18 +2091,9 @@ async function runWorkflowDetached(server, options, workflow, runId, resolvedVar
2053
2091
  }
2054
2092
  }
2055
2093
  async function executeResumeWorkflow(server, options, args, signal, progressToken) {
2056
- const runId = args["run_id"];
2094
+ const runId = runIdArg(args);
2057
2095
  const debug = args["debug"] === true;
2058
2096
  const receipt = args["receipt"] === true;
2059
- if (typeof runId !== "string" || runId === "") {
2060
- throw new McpError(ErrorCode.InvalidParams, 'Missing or invalid "run_id" parameter');
2061
- }
2062
- try {
2063
- validateRunId(runId);
2064
- }
2065
- catch {
2066
- throw new McpError(ErrorCode.InvalidParams, `Invalid run_id "${runId}". Must match [A-Za-z0-9_-]{1,64}.`);
2067
- }
2068
2097
  // Plan-review terminal state is authoritative even while the detached run's
2069
2098
  // finally still owns the in-process mutex. Read it before duplicate-run
2070
2099
  // arbitration so terminal-before-cleanup never leaks as "already running".
@@ -2117,6 +2146,7 @@ async function executeResumeWorkflow(server, options, args, signal, progressToke
2117
2146
  // Open this attempt before classifying an abort during reconstruction;
2118
2147
  // otherwise a prior terminal record would suppress markCancelled.
2119
2148
  await store.beginResumeAttempt();
2149
+ await ownership.bindStore(store);
2120
2150
  ownership.throwIfCancelled();
2121
2151
  releaseLifecycleBinding = options.contextBuilderLifecycleRouter?.bind(runId, store);
2122
2152
  const validatorRegistry = createValidatorRegistry();
@@ -2293,16 +2323,27 @@ async function executeResumeWorkflow(server, options, args, signal, progressToke
2293
2323
  summary,
2294
2324
  debugBlock: debug ? buildDebugBlock(runId, result, store) : undefined,
2295
2325
  });
2326
+ appendIdentityBlock(content, null, ownership.executionId);
2296
2327
  return { content };
2297
2328
  }
2298
2329
  catch (error) {
2330
+ // Only a bound attempt owns the persisted record. Before bind, a failed
2331
+ // beginResumeAttempt leaves in-memory state "pending" while disk still
2332
+ // holds the settled run's terminal bytes; persisting it would displace
2333
+ // that settled execution.
2334
+ const owned = ownership.executionId !== undefined;
2299
2335
  if (ownedSignal.aborted || error instanceof WorkflowCancellationError) {
2300
- await markWorkflowCancelled(store);
2336
+ if (owned)
2337
+ await markWorkflowCancelled(store);
2301
2338
  throw new WorkflowCancellationError();
2302
2339
  }
2340
+ // A verified-repair store is never bound: this is the pre-mutex rejection
2341
+ // reached when that read failed, so report it the same way (-32602).
2342
+ if (!owned && store?.runState.workflowName === VERIFIED_REPAIR_WORKFLOW)
2343
+ throw error;
2303
2344
  // Persist a terminal "failed" record for post-admission setup failures.
2304
2345
  // Idempotent: won't clobber an engine-emitted terminal status.
2305
- if (store != null) {
2346
+ if (owned && store != null) {
2306
2347
  try {
2307
2348
  await markWorkflowFailed(store, runId, error);
2308
2349
  }
@@ -2328,6 +2369,7 @@ async function executeResumeWorkflow(server, options, args, signal, progressToke
2328
2369
  summary: failureSummary,
2329
2370
  debugBlock: debug && store != null ? buildErrorDebugBlock(runId, store) : undefined,
2330
2371
  });
2372
+ appendIdentityBlock(content, null, ownership.executionId);
2331
2373
  return { content, isError: true };
2332
2374
  }
2333
2375
  finally {
@@ -2428,9 +2470,18 @@ function parseLifecycleForStatus(value) {
2428
2470
  return undefined;
2429
2471
  }
2430
2472
  }
2431
- async function executeWorkflowRunStatus(options, args) {
2473
+ function expectedExecutionId(args) {
2474
+ const expected = args["expected_execution_id"];
2475
+ if (expected === undefined)
2476
+ return undefined;
2477
+ if (typeof expected !== "string" || expected.length === 0 || expected.length > 128) {
2478
+ throw new McpError(ErrorCode.InvalidParams, "expected_execution_id must be a nonempty string of at most 128 characters");
2479
+ }
2480
+ return expected;
2481
+ }
2482
+ /** Shared by resume and pinned/unpinned status/wait, preserving their existing messages. */
2483
+ function runIdArg(args) {
2432
2484
  const runId = args["run_id"];
2433
- const includeResult = args["include_result"] === true;
2434
2485
  if (typeof runId !== "string" || runId === "") {
2435
2486
  throw new McpError(ErrorCode.InvalidParams, 'Missing or invalid "run_id" parameter');
2436
2487
  }
@@ -2440,12 +2491,242 @@ async function executeWorkflowRunStatus(options, args) {
2440
2491
  catch {
2441
2492
  throw new McpError(ErrorCode.InvalidParams, `Invalid run_id "${runId}". Must match [A-Za-z0-9_-]{1,64}.`);
2442
2493
  }
2443
- const read = await readRunStateForStatus(runId);
2494
+ return runId;
2495
+ }
2496
+ function identityError(runId, reason) {
2497
+ return {
2498
+ isError: true,
2499
+ content: [{ type: "text", text: JSON.stringify({ run_id: runId, error: reason }) }],
2500
+ };
2501
+ }
2502
+ /**
2503
+ * Project status for one bound execution and fence it. A settled execution is
2504
+ * fenced on the digest of the exact state bytes the projection parsed (one
2505
+ * state.json read on the success path); an active one only on provenance and
2506
+ * directory incarnation, since it legitimately republishes state.
2507
+ */
2508
+ async function projectGuardedStatus(options, args, entry) {
2509
+ const wasSettled = entry.settled;
2510
+ const wasStamped = entry.stamped;
2511
+ const guard = {};
2512
+ let result;
2513
+ try {
2514
+ result = await projectWorkflowRunStatus(options, args, guard);
2515
+ }
2516
+ catch (error) {
2517
+ const reread = wasSettled ? (await inspectPublication(entry.runDir))?.fingerprint ?? null : undefined;
2518
+ const failure = await completions.check(entry, reread);
2519
+ if (failure !== undefined)
2520
+ return { failure };
2521
+ if (error instanceof ContainedFileChangedError) {
2522
+ return { failure: wasSettled ? "identity_unavailable" : "state_read_unstable" };
2523
+ }
2524
+ throw error;
2525
+ }
2526
+ const failure = await completions.check(entry, wasSettled ? guard.stateDigest ?? null : undefined);
2527
+ if (failure !== undefined)
2528
+ return { failure };
2529
+ // Another process sharing the state dir can resume the run in place, which
2530
+ // keeps the directory incarnation; only the persisted id shows the change.
2531
+ if (wasStamped && guard.executionId !== entry.executionId) {
2532
+ return { failure: typeof guard.executionId === "string" ? "identity_changed" : "identity_unavailable" };
2533
+ }
2534
+ return { result };
2535
+ }
2536
+ async function executeWorkflowRunStatus(options, args) {
2537
+ const expected = expectedExecutionId(args);
2538
+ if (expected === undefined)
2539
+ return projectWorkflowRunStatus(options, args);
2540
+ const runId = runIdArg(args);
2541
+ const selected = completions.select(getRunStateDir(runId), expected);
2542
+ if (selected === "identity_unavailable") {
2543
+ const persisted = await projectPersistedSettledStatus(options, args, runId, expected);
2544
+ return "failure" in persisted ? identityError(runId, persisted.failure) : persisted.result;
2545
+ }
2546
+ if (typeof selected === "string")
2547
+ return identityError(runId, selected);
2548
+ if (selected === undefined)
2549
+ return identityError(runId, "identity_unavailable");
2550
+ const release = completions.retain(selected);
2551
+ try {
2552
+ const before = await completions.check(selected);
2553
+ if (before !== undefined)
2554
+ return identityError(runId, before);
2555
+ const fenced = await projectGuardedStatus(options, args, selected);
2556
+ return "failure" in fenced ? identityError(runId, fenced.failure) : fenced.result;
2557
+ }
2558
+ finally {
2559
+ release();
2560
+ }
2561
+ }
2562
+ async function executeWorkflowRunWait(options, args, signal) {
2563
+ const started = Date.now();
2564
+ const runId = runIdArg(args);
2565
+ const expected = expectedExecutionId(args);
2566
+ const selected = completions.select(getRunStateDir(runId), expected);
2567
+ let outcome;
2568
+ let entry;
2569
+ let persistedExecutionId;
2570
+ const release = typeof selected === "object" ? completions.retain(selected) : undefined;
2571
+ const respond = (projection = { run_id: runId }) => {
2572
+ if (signal.aborted)
2573
+ throw new WorkflowCancellationError();
2574
+ return {
2575
+ content: [{
2576
+ type: "text",
2577
+ text: JSON.stringify({
2578
+ ...projection,
2579
+ wait: {
2580
+ outcome,
2581
+ ...(entry !== undefined ? { execution_id: entry.executionId } : {}),
2582
+ ...(persistedExecutionId !== undefined ? { execution_id: persistedExecutionId } : {}),
2583
+ budget_ms: WORKFLOW_WAIT_BUDGET_MS,
2584
+ elapsed_ms: Math.max(0, Date.now() - started),
2585
+ },
2586
+ }),
2587
+ }],
2588
+ };
2589
+ };
2590
+ try {
2591
+ if (selected === "identity_unavailable" && expected !== undefined) {
2592
+ // No local record: settled only if state.json proves this execution ended.
2593
+ const persisted = await projectPersistedSettledStatus(options, { run_id: runId }, runId, expected);
2594
+ if (signal.aborted)
2595
+ throw new WorkflowCancellationError();
2596
+ if ("failure" in persisted) {
2597
+ outcome = persisted.failure;
2598
+ return respond();
2599
+ }
2600
+ outcome = "settled";
2601
+ persistedExecutionId = expected;
2602
+ return respond(JSON.parse(persisted.result.content[0].text));
2603
+ }
2604
+ if (typeof selected === "string")
2605
+ outcome = selected;
2606
+ else if (selected === undefined)
2607
+ outcome = "not_owned";
2608
+ else {
2609
+ entry = selected;
2610
+ outcome = await completions.wait(entry, signal);
2611
+ const failure = await completions.check(entry);
2612
+ if (failure !== undefined)
2613
+ outcome = failure;
2614
+ }
2615
+ if (signal.aborted)
2616
+ throw new WorkflowCancellationError();
2617
+ // Never inspect a replacement or unreadable state after provenance fails.
2618
+ if (outcome === "identity_changed" || outcome === "identity_unavailable")
2619
+ return respond();
2620
+ // Metadata only: the result body is fetched separately through the guarded status tool.
2621
+ if (entry !== undefined) {
2622
+ const fenced = await projectGuardedStatus(options, { run_id: runId }, entry);
2623
+ if (signal.aborted)
2624
+ throw new WorkflowCancellationError();
2625
+ if ("failure" in fenced) {
2626
+ // An unstable active read is a normal re-arm, never a hard error.
2627
+ outcome = fenced.failure === "state_read_unstable" ? "budget_elapsed" : fenced.failure;
2628
+ return respond();
2629
+ }
2630
+ return respond(JSON.parse(fenced.result.content[0].text));
2631
+ }
2632
+ const status = await projectWorkflowRunStatus(options, { run_id: runId });
2633
+ const projection = JSON.parse(status.content[0].text);
2634
+ if (projection["status"] === "not_found")
2635
+ outcome = "not_found";
2636
+ else if (projection["status"] === "orphaned")
2637
+ outcome = "orphaned";
2638
+ if (signal.aborted)
2639
+ throw new WorkflowCancellationError();
2640
+ return respond(projection);
2641
+ }
2642
+ finally {
2643
+ release?.();
2644
+ }
2645
+ }
2646
+ /**
2647
+ * A pin with no local record (bound by an earlier process, or evicted here) is
2648
+ * resolved from the executionId persisted in state.json. The id, terminal
2649
+ * status and released run.lock are checked on the exact bytes the projection
2650
+ * then renders. After the projection a second contained read must return the
2651
+ * same bytes with the lock still released, the directory incarnation must be
2652
+ * unchanged, and no local execution may have bound the run dir; otherwise the
2653
+ * read fails closed, since a foreign resume keeps the generation.
2654
+ */
2655
+ async function projectPersistedSettledStatus(options, args, runId, expected) {
2656
+ const runDir = getRunStateDir(runId);
2657
+ const latest = completions.select(runDir);
2658
+ if (latest !== undefined && !latest.settled)
2659
+ return { failure: "identity_changed" };
2660
+ const before = await readSessionLogGeneration(runDir);
2661
+ if (before.kind !== "available")
2662
+ return { failure: "identity_unavailable" };
2663
+ const guard = { persistedExecutionId: expected };
2664
+ let result;
2665
+ let projectionError;
2666
+ try {
2667
+ result = await projectWorkflowRunStatus(options, args, guard);
2668
+ }
2669
+ catch (error) {
2670
+ // Unreadable, oversized, malformed or persistently changing state.
2671
+ if (guard.persistedIdentity === undefined)
2672
+ return { failure: "identity_unavailable" };
2673
+ projectionError = error;
2674
+ }
2675
+ if (guard.persistedIdentity !== "matched")
2676
+ return { failure: guard.persistedIdentity ?? "identity_unavailable" };
2677
+ let reread;
2678
+ try {
2679
+ reread = await readRunStateForStatus(runId, true);
2680
+ }
2681
+ catch {
2682
+ return { failure: "identity_unavailable" };
2683
+ }
2684
+ if (reread == null)
2685
+ return { failure: "identity_unavailable" };
2686
+ // Same rule as the first check: only a different stamped id is a change.
2687
+ const rereadId = reread.state.executionId;
2688
+ if (typeof rereadId === "string" && rereadId !== expected)
2689
+ return { failure: "identity_changed" };
2690
+ if (reread.stateDigest !== guard.stateDigest || reread.lock !== "stale_or_absent") {
2691
+ return { failure: "identity_unavailable" };
2692
+ }
2693
+ const after = await readSessionLogGeneration(runDir);
2694
+ // A new local bind supersedes the pin; mere eviction of `latest` does not.
2695
+ const current = completions.select(runDir);
2696
+ if (current !== undefined && current !== latest)
2697
+ return { failure: "identity_changed" };
2698
+ if (after.kind !== "available")
2699
+ return { failure: "identity_unavailable" };
2700
+ if (after.sessionLogGeneration !== before.sessionLogGeneration)
2701
+ return { failure: "identity_changed" };
2702
+ if (result !== undefined)
2703
+ return { result };
2704
+ // Same mapping as the in-memory settled path: a racing contained read fails closed.
2705
+ if (projectionError instanceof ContainedFileChangedError)
2706
+ return { failure: "identity_unavailable" };
2707
+ throw projectionError;
2708
+ }
2709
+ async function projectWorkflowRunStatus(options, args, guard) {
2710
+ const runId = runIdArg(args);
2711
+ const includeResult = args["include_result"] === true;
2712
+ const read = await readRunStateForStatus(runId, guard !== undefined);
2444
2713
  if (read == null) {
2714
+ if (guard?.persistedExecutionId !== undefined)
2715
+ guard.persistedIdentity = "identity_unavailable";
2445
2716
  return {
2446
2717
  content: [{ type: "text", text: JSON.stringify({ status: "not_found", run_id: runId }) }],
2447
2718
  };
2448
2719
  }
2720
+ if (guard !== undefined) {
2721
+ guard.stateDigest = read.stateDigest;
2722
+ guard.executionId = read.state.executionId;
2723
+ }
2724
+ if (guard?.persistedExecutionId !== undefined) {
2725
+ // Checked before any further I/O, on the bytes this projection renders.
2726
+ guard.persistedIdentity = persistedIdentityFailure(read.state, read.lock, runId, guard.persistedExecutionId) ?? "matched";
2727
+ if (guard.persistedIdentity !== "matched")
2728
+ return { content: [] };
2729
+ }
2449
2730
  const { state, lock } = read;
2450
2731
  const status = (state.status === "running" || state.status === "pending") && lock === "stale_or_absent"
2451
2732
  ? "orphaned"
@@ -3028,7 +3309,7 @@ const RUN_WORKFLOW_SCHEMA = {
3028
3309
  async: {
3029
3310
  type: "boolean",
3030
3311
  default: false,
3031
- description: "When true, start the run detached and return {run_id, status:'running'} immediately; poll workflow_run_status for progress and result.",
3312
+ description: "When true, start detached and return {run_id, execution_id, status:'running'} immediately. Prefer workflow_run_wait for local completion, then workflow_run_status with expected_execution_id and include_result:true; status polling remains available for recovery. When false (blocking), the final content block carries execution_id once the run owned state.",
3032
3313
  },
3033
3314
  roster: {
3034
3315
  type: "string",
@@ -3121,6 +3402,12 @@ const LIST_WORKFLOWS_SCHEMA = {
3121
3402
  properties: {},
3122
3403
  additionalProperties: false,
3123
3404
  };
3405
+ const EXECUTION_ID_SCHEMA = {
3406
+ type: "string",
3407
+ minLength: 1,
3408
+ maxLength: 128,
3409
+ description: "Opaque execution identity (also persisted in run state) returned by run_workflow (async ack or trailing sync block), resume_workflow, or a bare wait. A fence, not a capability: it grants nothing and only makes reads fail closed (identity_changed/identity_unavailable) if they would observe a different execution.",
3410
+ };
3124
3411
  const WORKFLOW_RUN_STATUS_SCHEMA = {
3125
3412
  type: "object",
3126
3413
  properties: {
@@ -3128,6 +3415,7 @@ const WORKFLOW_RUN_STATUS_SCHEMA = {
3128
3415
  type: "string",
3129
3416
  pattern: "^[A-Za-z0-9_-]{1,64}$",
3130
3417
  },
3418
+ expected_execution_id: EXECUTION_ID_SCHEMA,
3131
3419
  include_result: {
3132
3420
  type: "boolean",
3133
3421
  default: false,
@@ -3137,6 +3425,15 @@ const WORKFLOW_RUN_STATUS_SCHEMA = {
3137
3425
  required: ["run_id"],
3138
3426
  additionalProperties: false,
3139
3427
  };
3428
+ const WORKFLOW_RUN_WAIT_SCHEMA = {
3429
+ type: "object",
3430
+ properties: {
3431
+ run_id: { type: "string", pattern: "^[A-Za-z0-9_-]{1,64}$" },
3432
+ expected_execution_id: EXECUTION_ID_SCHEMA,
3433
+ },
3434
+ required: ["run_id"],
3435
+ additionalProperties: false,
3436
+ };
3140
3437
  const CANCEL_WORKFLOW_SCHEMA = {
3141
3438
  type: "object",
3142
3439
  properties: {
@@ -3248,13 +3545,14 @@ export function getWorkflowToolDefinitions(workflowsPath, availableCliNames) {
3248
3545
  return [
3249
3546
  {
3250
3547
  name: "run_workflow",
3251
- description: `Start a new multi-stage workflow execution.${workflowSuffix}\nAccepts optional \`members\` and \`chairman\` parameters for council workflows (council-default, council-answer, council-code-review, council-debug, council-discover, council-plan-review) to override member/reviewer composition and chairman CLI adapter at runtime. Council runs include a \`resolved_roster\` of requested seat and delegate values (no provenance) on the async ack, a trailing sync content block, and workflow_run_status.`,
3548
+ description: `Start a new multi-stage workflow execution.${workflowSuffix}\nAccepts optional \`members\` and \`chairman\` parameters for council workflows (council-default, council-answer, council-code-review, council-debug, council-discover, council-plan-review) to override member/reviewer composition and chairman CLI adapter at runtime. Council runs include a \`resolved_roster\` of requested seat and delegate values (no provenance) on the async ack, a trailing sync content block, and workflow_run_status. Blocking runs end with one trailing identity block {resolved_roster?, execution_id?}; execution_id is present once the run owned state, absent when rejected before that.`,
3252
3549
  inputSchema: runSchema,
3253
3550
  },
3254
3551
  {
3255
3552
  name: "resume_workflow",
3256
3553
  description: "Resume a previously failed or cancelled workflow from its last checkpoint. " +
3257
- "Requires the original run_id and matching child config.",
3554
+ "Requires the original run_id and matching child config. " +
3555
+ "Once the resume owns the run, the final content block is {execution_id} for this attempt; pin workflow_run_wait/workflow_run_status to it. A resume rejected before then returns no execution_id and leaves the previous execution observable.",
3258
3556
  inputSchema: RESUME_WORKFLOW_SCHEMA,
3259
3557
  },
3260
3558
  {
@@ -3265,10 +3563,16 @@ export function getWorkflowToolDefinitions(workflowsPath, availableCliNames) {
3265
3563
  },
3266
3564
  {
3267
3565
  name: "workflow_run_status",
3268
- description: "Read-only status/progress of a workflow run. The full result is included only with include_result:true. Generic workflows preserve succeeded-final-call behavior; plan-review-receipts reports marker-driven availability and bytes independently of terminal status, with body-blind default polling. Every found run also carries a top-level `health` block {classification: \"ok\"|\"degraded\", reasons: string[]} computed read-time from persisted data — a run can be status:\"succeeded\" yet degraded when on_failure:continue swallowed failed calls or context preflight was partial/degraded (reasons: failed_calls_in_succeeded_run, context_partial, context_lifecycle_failed, coverage_degraded, delegate_fallback_used, context_external_gaps — the finalizer reported evidence gaps outside the delegate packet under external_gap_policy:\"degrade\", so conclusions depending on them are conditional). When a context-builder lifecycle snapshot is persisted, the `context` block additionally includes `lifecycle_status` and `coverage` {policy, status, required, selected, missing}. A parsed snapshot also projects `context.delegate.finalizer` {effective_enabled, execution_status, score?}; `score` is present only for a succeeded finalizer that recorded one, and the finalizer key is omitted when the lifecycle is missing, unparseable, or those fields are absent. While context preflight is still running (non-terminal lifecycle, no committed context yet) the `context` block is absent and a top-level `context_progress` {phase, status, updated_at, counts} reports scan progress instead. Found council runs also include a top-level `resolved_roster` of requested seat and delegate values (no provenance) when the run persisted one; older runs omit the key. Architecture comparisons additionally project comparison completeness and retained record availability independently of run execution status. Every found run has a separately versioned verification projection of historical inspected-at evidence; it establishes neither current source/runtime applicability nor owner acceptance.",
3566
+ description: "Read-only status/progress of a workflow run. The full result is included only with include_result:true. Generic workflows preserve succeeded-final-call behavior; plan-review-receipts reports marker-driven availability and bytes independently of terminal status, with body-blind default polling. Every found run also carries a top-level `health` block {classification: \"ok\"|\"degraded\", reasons: string[]} computed read-time from persisted data — a run can be status:\"succeeded\" yet degraded when on_failure:continue swallowed failed calls or context preflight was partial/degraded (reasons: failed_calls_in_succeeded_run, context_partial, context_lifecycle_failed, coverage_degraded, delegate_fallback_used, context_external_gaps — the finalizer reported evidence gaps outside the delegate packet under external_gap_policy:\"degrade\", so conclusions depending on them are conditional). When a context-builder lifecycle snapshot is persisted, the `context` block additionally includes `lifecycle_status` and `coverage` {policy, status, required, selected, missing}. A parsed snapshot also projects `context.delegate.finalizer` {effective_enabled, execution_status, score?}; `score` is present only for a succeeded finalizer that recorded one, and the finalizer key is omitted when the lifecycle is missing, unparseable, or those fields are absent. While context preflight is still running (non-terminal lifecycle, no committed context yet) the `context` block is absent and a top-level `context_progress` {phase, status, updated_at, counts} reports scan progress instead. Found council runs also include a top-level `resolved_roster` of requested seat and delegate values (no provenance) when the run persisted one; older runs omit the key. Architecture comparisons additionally project comparison completeness and retained record availability independently of run execution status. Every found run has a separately versioned verification projection of historical inspected-at evidence; it establishes neither current source/runtime applicability nor owner acceptance. With expected_execution_id the response is fenced to that execution: a mismatch is isError {run_id, error:\"identity_changed\"|\"identity_unavailable\"} with no result, and an active execution whose state kept changing through every bounded reread is isError {run_id, error:\"state_read_unstable\"} — retryable; re-issue the same pinned call after a short backoff.",
3269
3567
  inputSchema: WORKFLOW_RUN_STATUS_SCHEMA,
3270
3568
  annotations: { readOnlyHint: true },
3271
3569
  },
3570
+ {
3571
+ name: "workflow_run_wait",
3572
+ description: "Observe one local workflow execution through its cleanup fence for at most 30 seconds. Returns metadata status plus wait.outcome and execution_id; wait.budget_ms is the configured budget, not the time waited (see elapsed_ms). Re-arm budget_elapsed with the same identity; a persistently unstable state read on an active execution also re-arms as budget_elapsed, without projection. not_owned, orphaned and not_found return immediately: no local execution is witnessable here yet or any longer (a request still admitting has not bound one). expected_execution_id is a fence, not a capability. The settled fingerprint covers state.json bytes only, not result files. Cancellation detaches observation only. Unpinned foreign/historical state is not witnessed settlement; a pin whose execution reached terminal state settles from persisted state (e.g. after a restart). Fetch final results via workflow_run_status with expected_execution_id (it may return the retryable error state_read_unstable for an active execution; re-issue the same pinned call).",
3573
+ inputSchema: WORKFLOW_RUN_WAIT_SCHEMA,
3574
+ annotations: { readOnlyHint: true },
3575
+ },
3272
3576
  {
3273
3577
  name: "cancel_workflow",
3274
3578
  description: "Cancel a workflow run by run_id. Aborts a live in-process async run (returns status:'cancelling'; poll workflow_run_status until it reaches terminal 'cancelled'). Reaps an orphaned/no-live-worker run to terminal 'cancelled' (reaped:true). Refuses a lock-held run owned by another live worker or a blocking run (cancellable:false). Idempotent; not_found and already_terminal are soft results.",
@@ -3283,6 +3587,7 @@ export const WORKFLOW_TOOL_NAMES = new Set([
3283
3587
  "resume_workflow",
3284
3588
  "list_workflows",
3285
3589
  "workflow_run_status",
3590
+ "workflow_run_wait",
3286
3591
  "cancel_workflow",
3287
3592
  ]);
3288
3593
  /**
@@ -3301,6 +3606,8 @@ export async function handleWorkflowToolCall(server, options, name, args, signal
3301
3606
  return executeListWorkflows(options);
3302
3607
  case "workflow_run_status":
3303
3608
  return await executeWorkflowRunStatus(options, args);
3609
+ case "workflow_run_wait":
3610
+ return await executeWorkflowRunWait(options, args, signal);
3304
3611
  case "cancel_workflow":
3305
3612
  return await executeCancelWorkflow(options, args);
3306
3613
  default:
@@ -3314,8 +3621,8 @@ export async function handleWorkflowToolCall(server, options, name, args, signal
3314
3621
  * Register workflow tools on an MCP Server instance.
3315
3622
  *
3316
3623
  * This is the reusable core extracted from createWorkflowServer().
3317
- * It registers five tools: run_workflow, resume_workflow, list_workflows,
3318
- * workflow_run_status, cancel_workflow.
3624
+ * It registers six tools: run_workflow, resume_workflow, list_workflows,
3625
+ * workflow_run_status, workflow_run_wait, cancel_workflow.
3319
3626
  *
3320
3627
  * The `options.createBackend` factory determines how clink calls are
3321
3628
  * dispatched — callers provide either a child-process or in-process backend.