@bastani/atomic 0.9.9-alpha.2 → 0.9.9-alpha.4

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 (79) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/builtin/cursor/CHANGELOG.md +12 -0
  3. package/dist/builtin/cursor/package.json +2 -2
  4. package/dist/builtin/intercom/CHANGELOG.md +13 -0
  5. package/dist/builtin/intercom/README.md +10 -3
  6. package/dist/builtin/intercom/broker/client.ts +2 -0
  7. package/dist/builtin/intercom/broker/send-handler.ts +22 -20
  8. package/dist/builtin/intercom/foreground-detach-handoff.ts +3 -1
  9. package/dist/builtin/intercom/inbound-idle-queue.ts +82 -0
  10. package/dist/builtin/intercom/index-heavy.ts +47 -37
  11. package/dist/builtin/intercom/intercom-tool.ts +6 -4
  12. package/dist/builtin/intercom/intercom-utils.ts +30 -0
  13. package/dist/builtin/intercom/lifecycle.ts +8 -5
  14. package/dist/builtin/intercom/package.json +1 -1
  15. package/dist/builtin/intercom/reply-tracker.ts +19 -19
  16. package/dist/builtin/intercom/session-target.ts +68 -0
  17. package/dist/builtin/intercom/skills/intercom/SKILL.md +7 -4
  18. package/dist/builtin/intercom/source-ownership.ts +34 -0
  19. package/dist/builtin/intercom/subagent-relay.ts +45 -20
  20. package/dist/builtin/intercom/terminal-ordering-barrier.ts +111 -0
  21. package/dist/builtin/intercom/types.ts +5 -0
  22. package/dist/builtin/mcp/CHANGELOG.md +12 -0
  23. package/dist/builtin/mcp/package.json +1 -1
  24. package/dist/builtin/subagents/CHANGELOG.md +12 -0
  25. package/dist/builtin/subagents/package.json +1 -1
  26. package/dist/builtin/subagents/src/extension/startup-maintenance.ts +2 -1
  27. package/dist/builtin/subagents/src/runs/background/notify.ts +53 -9
  28. package/dist/builtin/subagents/src/shared/types-config.ts +1 -0
  29. package/dist/builtin/web-access/CHANGELOG.md +12 -0
  30. package/dist/builtin/web-access/package.json +1 -1
  31. package/dist/builtin/workflows/CHANGELOG.md +14 -0
  32. package/dist/builtin/workflows/README.md +2 -0
  33. package/dist/builtin/workflows/package.json +1 -1
  34. package/dist/builtin/workflows/src/extension/extension-runtime-state.ts +102 -48
  35. package/dist/builtin/workflows/src/extension/render-result.ts +11 -2
  36. package/dist/builtin/workflows/src/extension/workflow-command-registration.ts +11 -8
  37. package/dist/builtin/workflows/src/extension/workflow-command-surfaces.ts +23 -0
  38. package/dist/builtin/workflows/src/extension/workflow-reload-coordinator.ts +66 -0
  39. package/dist/builtin/workflows/src/extension/workflow-reload-report.ts +39 -0
  40. package/dist/builtin/workflows/src/extension/workflow-run-control-command.ts +47 -25
  41. package/dist/builtin/workflows/src/extension/workflow-targets.ts +0 -10
  42. package/dist/builtin/workflows/src/extension/workflow-tool-control.ts +21 -17
  43. package/dist/builtin/workflows/src/extension/workflow-tool.ts +20 -3
  44. package/dist/core/agent-session-extension-bindings.d.ts.map +1 -1
  45. package/dist/core/agent-session-extension-bindings.js +9 -0
  46. package/dist/core/agent-session-extension-bindings.js.map +1 -1
  47. package/dist/core/agent-session-message-queue.d.ts +4 -1
  48. package/dist/core/agent-session-message-queue.d.ts.map +1 -1
  49. package/dist/core/agent-session-message-queue.js +35 -0
  50. package/dist/core/agent-session-message-queue.js.map +1 -1
  51. package/dist/core/agent-session-methods.d.ts +2 -1
  52. package/dist/core/agent-session-methods.d.ts.map +1 -1
  53. package/dist/core/agent-session-methods.js.map +1 -1
  54. package/dist/core/extensions/api-types.d.ts +3 -1
  55. package/dist/core/extensions/api-types.d.ts.map +1 -1
  56. package/dist/core/extensions/api-types.js.map +1 -1
  57. package/dist/core/extensions/index.d.ts +1 -1
  58. package/dist/core/extensions/index.d.ts.map +1 -1
  59. package/dist/core/extensions/index.js.map +1 -1
  60. package/dist/core/extensions/loader-api.d.ts.map +1 -1
  61. package/dist/core/extensions/loader-api.js +4 -0
  62. package/dist/core/extensions/loader-api.js.map +1 -1
  63. package/dist/core/extensions/loader-runtime.d.ts.map +1 -1
  64. package/dist/core/extensions/loader-runtime.js +1 -0
  65. package/dist/core/extensions/loader-runtime.js.map +1 -1
  66. package/dist/core/extensions/message-types.d.ts +3 -0
  67. package/dist/core/extensions/message-types.d.ts.map +1 -1
  68. package/dist/core/extensions/message-types.js.map +1 -1
  69. package/dist/core/extensions/runner.d.ts.map +1 -1
  70. package/dist/core/extensions/runner.js +1 -0
  71. package/dist/core/extensions/runner.js.map +1 -1
  72. package/dist/core/extensions/runtime-types.d.ts +3 -1
  73. package/dist/core/extensions/runtime-types.d.ts.map +1 -1
  74. package/dist/core/extensions/runtime-types.js.map +1 -1
  75. package/docs/extensions.md +13 -0
  76. package/docs/subagents.md +2 -0
  77. package/docs/workflows.md +11 -3
  78. package/npm-shrinkwrap.json +23 -23
  79. package/package.json +2 -2
@@ -6,6 +6,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.9.9-alpha.4] - 2026-07-15
10
+
11
+ ### Fixed
12
+
13
+ - Fixed workflow resource reload to build and atomically publish a fresh registry for additions, edits, renames, deletions, config paths, conventional/legacy directories, and package resources without restarting Atomic. Reloads now serialize and coalesce, reject stale session generations, retain the active registry on fatal failures, remain safe during in-flight runs, and return visible config/discovery diagnostics through both slash-command and tool surfaces while preserving valid siblings.
14
+ - Fixed exact paused top-level live `/workflow resume <id>` targets to resume before enumerating retained completed durable history, preserving live-over-durable precedence and keeping headless command output responsive when the durable catalog is large, while exact nested child IDs remain excluded from the top-level resume namespace. Slash-dispatch tests now isolate durable state in memory and restore the backend after each test so they never read or populate a user's workflow history.
15
+ - Isolated lazy-startup continuation tests with fresh in-memory durable backends and a completed-catalog regression guard, preventing test runs from enumerating or populating the user's real durable workflow history while preserving failed-run resource-loading coverage.
16
+
17
+ ## [0.9.9-alpha.3] - 2026-07-14
18
+
19
+ ### Fixed
20
+
21
+ - Made `workflow({ action: "status", runId })` apply the same exact-or-unique-prefix contract as the other run actions. A prefix shared by multiple retained runs now returns the standard ambiguity diagnostic instead of silently inspecting the first match, so abbreviated run IDs printed by status surfaces remain safe and actionable.
22
+
9
23
  ## [0.9.9-alpha.2] - 2026-07-14
10
24
 
11
25
  ### Changed
@@ -29,6 +29,8 @@ Adding workflow files under `.atomic/workflows/` (project scope) or `~/.atomic/a
29
29
  }
30
30
  ```
31
31
 
32
+ After Atomic is running, use `/workflow reload` or the workflow tool's `reload` action to rescan all workflow sources in process. Additions, edits, renames, deletions, config changes, and package-resource changes become visible immediately to list/get/inputs/help/completion/invocation surfaces. Reload requests are serialized/coalesced and publish a complete replacement registry; an in-flight workflow keeps its original definition while later calls use the new registry. Fatal refresh failures retain the prior registry, and skipped malformed or missing resources are reported with actionable diagnostics while valid siblings remain available.
33
+
32
34
  ### Workflow lifecycle notifications
33
35
 
34
36
  Workflow lifecycle notices are enabled by default. They send steer prompts into the main chat/model context when a run completes, fails, or ends blocked. Awaiting-input prompts are tracked for dedupe/restore, but they do not wake the main chat agent. Configure lifecycle tracking in the same extension config file:
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bastani/workflows",
3
- "version": "0.9.9-alpha.2",
3
+ "version": "0.9.9-alpha.4",
4
4
  "private": true,
5
5
  "description": "Atomic extension for multi-stage workflow authoring and execution.",
6
6
  "contributors": [
@@ -36,7 +36,11 @@ import {
36
36
  } from "./hil-answer-notifications.js";
37
37
  import type { ExtensionAPI, PiModelContext } from "./public-types.js";
38
38
  import { makeMcpPort, makePersistencePort } from "./workflow-ports.js";
39
- import { inFlightRunCount, reloadBlockedMessage, WorkflowReloadBlockedError } from "./workflow-targets.js";
39
+ import { createWorkflowReloadCoordinator } from "./workflow-reload-coordinator.js";
40
+ import {
41
+ workflowReloadDiagnostics,
42
+ type WorkflowReloadReport,
43
+ } from "./workflow-reload-report.js";
40
44
 
41
45
  export interface WorkflowExtensionRuntimeState {
42
46
  persistenceRef: { current: WorkflowPersistencePort | undefined };
@@ -49,8 +53,8 @@ export interface WorkflowExtensionRuntimeState {
49
53
  runtimeForContext(ctx?: PiModelContext): ExtensionRuntime;
50
54
  resetWorkflowDiscoveryForSession(): void;
51
55
  ensureWorkflowConfigLoaded(): Promise<void>;
52
- ensureWorkflowResourcesLoaded(options?: { allowInFlight?: boolean }): Promise<void>;
53
- reloadWorkflowResources(options?: { allowInFlight?: boolean }): Promise<void>;
56
+ ensureWorkflowResourcesLoaded(): Promise<void>;
57
+ reloadWorkflowResources(): Promise<WorkflowReloadReport>;
54
58
  startWorkflowDiscoveryWarmup(onSettled?: () => void): void;
55
59
  runWithLifecycleSuppressedForPolicy<T>(policy: WorkflowExecutionPolicy, fn: () => Promise<T>): Promise<T>;
56
60
  setNotificationsActive(active: boolean): void;
@@ -198,10 +202,9 @@ export function createWorkflowExtensionRuntimeState(
198
202
  });
199
203
  }
200
204
 
201
- let workflowReloadQueue: Promise<void> = Promise.resolve();
202
-
203
- let lazyDiscoveryPromise: Promise<void> | null = null;
205
+ let lazyDiscoveryPromise: Promise<WorkflowReloadReport> | null = null;
204
206
  let workflowDiscoveryGeneration = 0;
207
+ let activeResourceGeneration = 0;
205
208
  function deferToMacrotask(): Promise<void> {
206
209
  return new Promise((resolve) => setImmediate(resolve));
207
210
  }
@@ -245,7 +248,6 @@ export function createWorkflowExtensionRuntimeState(
245
248
  workflowDiscoveryGeneration += 1;
246
249
  discoveryRef.current = null;
247
250
  lazyDiscoveryPromise = null;
248
- workflowReloadQueue = Promise.resolve();
249
251
  rebuildRuntime(startupDiscovery.registry);
250
252
  }
251
253
 
@@ -257,13 +259,36 @@ export function createWorkflowExtensionRuntimeState(
257
259
  rebuildRuntime();
258
260
  }
259
261
 
260
- function queueWorkflowResourceReload(options: { allowInFlight?: boolean } | undefined, generation: number): Promise<void> {
261
- const reload = workflowReloadQueue.then(() => reloadWorkflowResourcesNow(options, generation));
262
- workflowReloadQueue = reload.catch(() => {});
263
- return reload;
262
+ function supersededReloadReport(
263
+ coalescedRequests: number,
264
+ configResult?: ConfigLoadResult,
265
+ result?: DiscoveryResult,
266
+ ): WorkflowReloadReport {
267
+ return {
268
+ outcome: "superseded",
269
+ generation: activeResourceGeneration,
270
+ workflowCount: runtimeRef.current.registry.names().length,
271
+ coalescedRequests,
272
+ diagnostics: workflowReloadDiagnostics(configResult?.diagnostics ?? [], result?.errors ?? []),
273
+ };
274
+ }
275
+
276
+ function failedReloadReport(
277
+ error: unknown,
278
+ coalescedRequests: number,
279
+ configResult?: ConfigLoadResult,
280
+ ): WorkflowReloadReport {
281
+ return {
282
+ outcome: "failed",
283
+ error: error instanceof Error ? error.message : String(error),
284
+ generation: activeResourceGeneration,
285
+ workflowCount: runtimeRef.current.registry.names().length,
286
+ coalescedRequests,
287
+ diagnostics: workflowReloadDiagnostics(configResult?.diagnostics ?? [], []),
288
+ };
264
289
  }
265
290
 
266
- function trackLazyDiscovery(pending: Promise<void>): Promise<void> {
291
+ function trackLazyDiscovery(pending: Promise<WorkflowReloadReport>): Promise<WorkflowReloadReport> {
267
292
  lazyDiscoveryPromise = pending;
268
293
  void pending.finally(() => {
269
294
  if (lazyDiscoveryPromise === pending) lazyDiscoveryPromise = null;
@@ -271,49 +296,74 @@ export function createWorkflowExtensionRuntimeState(
271
296
  return pending;
272
297
  }
273
298
 
274
- function reloadWorkflowResources(options?: { allowInFlight?: boolean }): Promise<void> {
275
- const generation = workflowDiscoveryGeneration;
276
- return trackLazyDiscovery(queueWorkflowResourceReload(options, generation));
299
+ function reloadWorkflowResources(): Promise<WorkflowReloadReport> {
300
+ return trackLazyDiscovery(reloadCoordinator.request(workflowDiscoveryGeneration));
277
301
  }
278
302
  async function loadPackageWorkflowPaths(): Promise<string[]> {
279
303
  const packageResources = (await pi.refreshWorkflowResources?.()) ?? pi.getWorkflowResources?.() ?? [];
280
304
  return packageResources.filter((resource) => resource.enabled !== false).map((resource) => resource.path);
281
305
  }
282
- async function reloadWorkflowResourcesNow(options: { allowInFlight?: boolean } | undefined, generation: number): Promise<void> {
283
- const activeRuns = inFlightRunCount();
284
- if (options?.allowInFlight !== true && activeRuns > 0) {
285
- throw new WorkflowReloadBlockedError(reloadBlockedMessage(activeRuns));
286
- }
287
- if (options?.allowInFlight === true && activeRuns > 0 && process.env.ATOMIC_WORKFLOW_DEBUG === "1") {
288
- console.warn(`Workflow reload bypassed in-flight guard with ${activeRuns} active run(s).`);
306
+ async function reloadWorkflowResourcesNow(
307
+ discoveryGeneration: number,
308
+ coalescedRequests: number,
309
+ ): Promise<WorkflowReloadReport> {
310
+ if (!isWorkflowDiscoveryCurrent(discoveryGeneration)) {
311
+ return supersededReloadReport(coalescedRequests);
289
312
  }
290
313
  const configResult = await loadWorkflowConfig();
291
- if (!isWorkflowDiscoveryCurrent(generation)) return;
292
- applyWorkflowConfig(configResult);
293
- const hasGlobal = configResult.globalConfig != null;
294
- const hasProject = configResult.projectConfig != null;
295
- const discoveryConfig = hasGlobal || hasProject
296
- ? toScopedDiscoveryConfig(configResult.globalConfig ?? null, configResult.projectConfig ?? null, { projectRoot: process.cwd() })
297
- : undefined;
298
- const packageWorkflowPaths = await loadPackageWorkflowPaths();
299
- if (!isWorkflowDiscoveryCurrent(generation)) return;
300
- const result = await discoverWorkflows({ config: discoveryConfig, packageWorkflowPaths });
301
- if (!isWorkflowDiscoveryCurrent(generation)) return;
302
- discoveryRef.current = result;
303
- rebuildRuntime(result.registry);
314
+ if (!isWorkflowDiscoveryCurrent(discoveryGeneration)) {
315
+ return supersededReloadReport(coalescedRequests, configResult);
316
+ }
317
+ try {
318
+ const hasGlobal = configResult.globalConfig != null;
319
+ const hasProject = configResult.projectConfig != null;
320
+ const discoveryConfig = hasGlobal || hasProject
321
+ ? toScopedDiscoveryConfig(configResult.globalConfig ?? null, configResult.projectConfig ?? null, { projectRoot: process.cwd() })
322
+ : undefined;
323
+ const packageWorkflowPaths = await loadPackageWorkflowPaths();
324
+ if (!isWorkflowDiscoveryCurrent(discoveryGeneration)) {
325
+ return supersededReloadReport(coalescedRequests, configResult);
326
+ }
327
+ const result = await discoverWorkflows({ config: discoveryConfig, packageWorkflowPaths });
328
+ if (!isWorkflowDiscoveryCurrent(discoveryGeneration)) {
329
+ return supersededReloadReport(coalescedRequests, configResult, result);
330
+ }
331
+
332
+ // Commit config, diagnostics, and the replacement runtime synchronously,
333
+ // after every fallible/awaited discovery step has completed.
334
+ applyWorkflowConfig(configResult);
335
+ discoveryRef.current = result;
336
+ rebuildRuntime(result.registry);
337
+ activeResourceGeneration += 1;
338
+ return {
339
+ outcome: "applied",
340
+ generation: activeResourceGeneration,
341
+ workflowCount: result.registry.names().length,
342
+ coalescedRequests,
343
+ diagnostics: workflowReloadDiagnostics(configResult.diagnostics, result.errors),
344
+ };
345
+ } catch (error) {
346
+ return failedReloadReport(error, coalescedRequests, configResult);
347
+ }
304
348
  }
305
349
 
306
- async function ensureWorkflowResourcesLoaded(options?: { allowInFlight?: boolean }): Promise<void> {
350
+ const reloadCoordinator = createWorkflowReloadCoordinator(async (discoveryGeneration, coalescedRequests) => {
351
+ try {
352
+ return await reloadWorkflowResourcesNow(discoveryGeneration, coalescedRequests);
353
+ } catch (error) {
354
+ return failedReloadReport(error, coalescedRequests);
355
+ }
356
+ });
357
+
358
+ async function ensureWorkflowResourcesLoaded(): Promise<void> {
307
359
  while (!pi.disableAsyncDiscovery && discoveryRef.current === null) {
308
- const generation = workflowDiscoveryGeneration;
309
- const pending = lazyDiscoveryPromise ?? trackLazyDiscovery(
310
- queueWorkflowResourceReload({ allowInFlight: options?.allowInFlight ?? true }, generation),
311
- );
312
- await pending;
313
- if (!isWorkflowDiscoveryCurrent(generation)) continue;
314
- if (discoveryRef.current === null) {
315
- lazyDiscoveryPromise = null;
316
- }
360
+ const discoveryGeneration = workflowDiscoveryGeneration;
361
+ const pending = lazyDiscoveryPromise
362
+ ?? trackLazyDiscovery(reloadCoordinator.request(discoveryGeneration));
363
+ const report = await pending;
364
+ if (report.outcome === "failed") throw new Error(report.error);
365
+ if (!isWorkflowDiscoveryCurrent(discoveryGeneration)) continue;
366
+ if (discoveryRef.current === null) lazyDiscoveryPromise = null;
317
367
  }
318
368
  }
319
369
 
@@ -321,10 +371,14 @@ export function createWorkflowExtensionRuntimeState(
321
371
  if (pi.disableAsyncDiscovery || discoveryRef.current !== null || lazyDiscoveryPromise !== null) return;
322
372
  const notificationStart = notificationGeneration;
323
373
  const discoveryStart = workflowDiscoveryGeneration;
324
- const pending = (async () => {
374
+ const pending = (async (): Promise<WorkflowReloadReport> => {
325
375
  await deferToMacrotask();
326
- if (!isWorkflowDiscoveryCurrent(discoveryStart)) return;
327
- await queueWorkflowResourceReload({ allowInFlight: true }, discoveryStart);
376
+ if (!isWorkflowDiscoveryCurrent(discoveryStart)) {
377
+ return supersededReloadReport(1);
378
+ }
379
+ const report = await reloadCoordinator.request(discoveryStart);
380
+ if (report.outcome === "failed") throw new Error(report.error);
381
+ return report;
328
382
  })();
329
383
  lazyDiscoveryPromise = pending;
330
384
  const isCurrentWarmup = (): boolean => lazyDiscoveryPromise === pending
@@ -16,6 +16,7 @@ import type { WorkflowDetails } from "../shared/types.js";
16
16
  import type { RunDetail } from "../runs/background/status.js";
17
17
  import { renderInputsSchema } from "../shared/render-inputs-schema.js";
18
18
  import { renderStatusList } from "../tui/status-list.js";
19
+ import type { WorkflowReloadReport } from "./workflow-reload-report.js";
19
20
  import { renderRunDetail } from "../tui/run-detail.js";
20
21
  import { renderWorkflowList } from "../tui/workflow-list.js";
21
22
  import { deriveGraphTheme } from "../tui/graph-theme.js";
@@ -23,6 +24,7 @@ import { renderDispatchConfirm } from "../tui/dispatch-confirm.js";
23
24
  import type { WorkflowInputValues, WorkflowOutputValues } from "../shared/types.js";
24
25
  import { renderRoundedBox } from "../tui/chat-surface.js";
25
26
  import { truncateToWidth } from "../tui/text-helpers.js";
27
+ import { wrapTextWithAnsi } from "@earendil-works/pi-tui";
26
28
 
27
29
  // ---------------------------------------------------------------------------
28
30
  // Result variants
@@ -138,7 +140,7 @@ type TranscriptResult = {
138
140
  };
139
141
  type SendResult = { action: "send"; runId: string; stageId: string; delivery: string; status: "ok" | "noop"; message: string };
140
142
  type PauseResult = { action: "pause"; runId: string; status: string; message: string };
141
- type ReloadResult = { action: "reload"; status: "ok" | "noop"; message: string };
143
+ type ReloadResult = WorkflowReloadReport & { action: "reload"; status: "ok" | "noop"; message: string };
142
144
  type InterruptResult = { action: "interrupt"; runId: string; status: string; message: string };
143
145
  type KillResult = { action: "kill"; runId: string; status: string; message: string };
144
146
  type ResumeResult = { action: "resume"; runId: string; status: string; message: string };
@@ -201,6 +203,13 @@ function fitLine(line: string, width?: number): string {
201
203
  return truncateToWidth(line, width, "…");
202
204
  }
203
205
 
206
+ function noticeBodyLines(message: string, width?: number): string[] {
207
+ return message.split(/\r?\n/).flatMap((line) => {
208
+ if (line.length === 0 || width === undefined || width <= 0) return [line];
209
+ return wrapTextWithAnsi(line, width);
210
+ }).map((line) => ` ${line} `);
211
+ }
212
+
204
213
  function renderNotice(
205
214
  title: string,
206
215
  message: string,
@@ -212,7 +221,7 @@ function renderNotice(
212
221
  const contentWidth = width && width > 0 ? Math.max(1, width - 4) : undefined;
213
222
  return renderRoundedBox({
214
223
  title,
215
- bodyLines: [` ${fitLine(message, contentWidth)} `],
224
+ bodyLines: noticeBodyLines(message, contentWidth),
216
225
  theme,
217
226
  width,
218
227
  });
@@ -23,22 +23,25 @@ import {
23
23
  type WorkflowCommandHandler,
24
24
  type WorkflowCommandOutputDetails,
25
25
  } from "./workflow-command-utils.js";
26
- import { emitTerminalRunDetailSurface, formatWorkflowResourceLoadWarning } from "./workflow-command-surfaces.js";
26
+ import {
27
+ emitTerminalRunDetailSurface,
28
+ formatWorkflowReloadReport,
29
+ formatWorkflowResourceLoadWarning,
30
+ } from "./workflow-command-surfaces.js";
27
31
  import { handleRunControlCommand, type WorkflowRunControlDeps } from "./workflow-run-control-command.js";
28
32
  import { workflowPolicyFromContext } from "./workflow-policy.js";
29
33
  import {
30
- inFlightRunCount,
31
- reloadBlockedMessage,
32
34
  reloadFailureMessage,
33
35
  resolveRunIdPrefix,
34
36
  overlaySurfaceFromContext,
35
37
  } from "./workflow-targets.js";
38
+ import { normalizeWorkflowReloadReport, type WorkflowReloadReport } from "./workflow-reload-report.js";
36
39
 
37
40
  export interface WorkflowSlashCommandDeps {
38
41
  runtimeProxy: ExtensionRuntime;
39
42
  runtimeForContext: (ctx?: PiCommandContext) => ExtensionRuntime;
40
43
  overlay: GraphOverlayPort;
41
- reloadWorkflowResources: () => Promise<void> | void;
44
+ reloadWorkflowResources: () => Promise<WorkflowReloadReport | void> | void;
42
45
  ensureWorkflowResourcesLoaded: () => Promise<void> | void;
43
46
  runWithLifecycleSuppressedForPolicy: <T>(
44
47
  policy: WorkflowExecutionPolicy,
@@ -141,11 +144,11 @@ async function workflowSlashHandler(
141
144
  return;
142
145
  }
143
146
  if (subcommand === "reload") {
144
- const activeRuns = inFlightRunCount();
145
- if (activeRuns > 0) return fail(reloadBlockedMessage(activeRuns));
146
147
  try {
147
- await deps.reloadWorkflowResources();
148
- print("Reloaded workflow resources.");
148
+ const report = normalizeWorkflowReloadReport(await deps.reloadWorkflowResources());
149
+ const message = formatWorkflowReloadReport(report);
150
+ if (report.outcome === "applied") print(message);
151
+ else fail(message);
149
152
  } catch (error) {
150
153
  fail(reloadFailureMessage(error));
151
154
  }
@@ -7,6 +7,7 @@ import type { DiscoveryResult } from "./discovery.js";
7
7
  import type { WorkflowToolResult } from "./render-result.js";
8
8
  import type { ExtensionAPI } from "./public-types.js";
9
9
  import { isRunStatus } from "./workflow-targets.js";
10
+ import type { WorkflowReloadReport } from "./workflow-reload-report.js";
10
11
 
11
12
  function fallbackRunDetailFromResult(
12
13
  workflowName: string,
@@ -54,6 +55,28 @@ export function formatWorkflowResourceLoadWarning(error: unknown): string {
54
55
  "Using the currently loaded workflow registry; run `/workflow reload` after fixing the issue.",
55
56
  ].join("\n");
56
57
  }
58
+
59
+ export function formatWorkflowReloadReport(report: WorkflowReloadReport, reason?: string): string {
60
+ const reasonSuffix = reason?.trim() ? ` (${reason.trim()})` : "";
61
+ const legacySuccess = report.generation === 0 && report.workflowCount === 0 && report.diagnostics.length === 0;
62
+ const headline = report.outcome === "applied"
63
+ ? legacySuccess
64
+ ? `Reloaded workflow resources${reasonSuffix}.`
65
+ : `Reloaded workflow resources${reasonSuffix}. ${report.workflowCount} workflow(s), generation ${report.generation}.`
66
+ : report.outcome === "failed"
67
+ ? `Workflow resources could not be refreshed; the current registry was retained: ${report.error}`
68
+ : "Workflow reload was superseded by a newer session; no stale resources were applied.";
69
+ if (report.diagnostics.length === 0) return headline;
70
+
71
+ const lines = report.diagnostics.map((diagnostic) =>
72
+ `- [${diagnostic.level} ${diagnostic.code}] ${diagnostic.source ?? `workflow ${diagnostic.phase}`}: ${diagnostic.message}`
73
+ );
74
+ return [
75
+ headline,
76
+ `Workflow discovery diagnostics (${report.diagnostics.length}): some resources were skipped or need attention.`,
77
+ ...lines,
78
+ ].join("\n");
79
+ }
57
80
  export function formatStartupDiagnostics(
58
81
  configResult: ConfigLoadResult | null,
59
82
  discoveryResult: DiscoveryResult | null,
@@ -0,0 +1,66 @@
1
+ import type { WorkflowReloadReport } from "./workflow-reload-report.js";
2
+
3
+ type ReloadBatch = {
4
+ readonly discoveryGeneration: number;
5
+ requestCount: number;
6
+ readonly promise: Promise<WorkflowReloadReport>;
7
+ readonly resolve: (report: WorkflowReloadReport) => void;
8
+ readonly reject: (error: Error) => void;
9
+ };
10
+
11
+ export interface WorkflowReloadCoordinator {
12
+ request(discoveryGeneration: number): Promise<WorkflowReloadReport>;
13
+ }
14
+
15
+ function createBatch(discoveryGeneration: number): ReloadBatch {
16
+ let resolve: (report: WorkflowReloadReport) => void = () => undefined;
17
+ let reject: (error: Error) => void = () => undefined;
18
+ const promise = new Promise<WorkflowReloadReport>((resolvePromise, rejectPromise) => {
19
+ resolve = resolvePromise;
20
+ reject = (error) => rejectPromise(error);
21
+ });
22
+ return { discoveryGeneration, requestCount: 0, promise, resolve, reject };
23
+ }
24
+
25
+ /**
26
+ * Runs one reload at a time. Requests coalesce only with the latest queued pass
27
+ * from the same session generation; generation boundaries remain ordered.
28
+ */
29
+ export function createWorkflowReloadCoordinator(
30
+ reload: (discoveryGeneration: number, coalescedRequests: number) => Promise<WorkflowReloadReport>,
31
+ ): WorkflowReloadCoordinator {
32
+ const pending: ReloadBatch[] = [];
33
+ let draining = false;
34
+
35
+ const drain = async (): Promise<void> => {
36
+ if (draining) return;
37
+ draining = true;
38
+ try {
39
+ while (pending.length > 0) {
40
+ const batch = pending.shift()!;
41
+ try {
42
+ const report = await reload(batch.discoveryGeneration, batch.requestCount);
43
+ batch.resolve(report);
44
+ } catch (error) {
45
+ batch.reject(error instanceof Error ? error : new Error(String(error)));
46
+ }
47
+ }
48
+ } finally {
49
+ draining = false;
50
+ if (pending.length > 0) void drain();
51
+ }
52
+ };
53
+
54
+ return {
55
+ request(discoveryGeneration): Promise<WorkflowReloadReport> {
56
+ let batch = pending.at(-1);
57
+ if (batch?.discoveryGeneration !== discoveryGeneration) {
58
+ batch = createBatch(discoveryGeneration);
59
+ pending.push(batch);
60
+ }
61
+ batch.requestCount += 1;
62
+ queueMicrotask(() => void drain());
63
+ return batch.promise;
64
+ },
65
+ };
66
+ }
@@ -0,0 +1,39 @@
1
+ import type { ConfigDiagnostic } from "./config-loader.js";
2
+ import type { DiscoveryDiagnostic } from "./discovery.js";
3
+
4
+ export type WorkflowReloadDiagnostic =
5
+ | (ConfigDiagnostic & { readonly phase: "config" })
6
+ | (DiscoveryDiagnostic & { readonly phase: "discovery" });
7
+
8
+ type WorkflowReloadReportState = {
9
+ readonly generation: number;
10
+ readonly workflowCount: number;
11
+ readonly coalescedRequests: number;
12
+ readonly diagnostics: WorkflowReloadDiagnostic[];
13
+ };
14
+
15
+ export type WorkflowReloadReport = WorkflowReloadReportState & (
16
+ | { readonly outcome: "applied" }
17
+ | { readonly outcome: "failed"; readonly error: string }
18
+ | { readonly outcome: "superseded" }
19
+ );
20
+
21
+ export function normalizeWorkflowReloadReport(report: WorkflowReloadReport | void): WorkflowReloadReport {
22
+ return report ?? {
23
+ outcome: "applied",
24
+ generation: 0,
25
+ workflowCount: 0,
26
+ coalescedRequests: 1,
27
+ diagnostics: [],
28
+ };
29
+ }
30
+
31
+ export function workflowReloadDiagnostics(
32
+ configDiagnostics: readonly ConfigDiagnostic[],
33
+ discoveryDiagnostics: readonly DiscoveryDiagnostic[],
34
+ ): WorkflowReloadDiagnostic[] {
35
+ return [
36
+ ...configDiagnostics.map((diagnostic) => ({ ...diagnostic, phase: "config" as const })),
37
+ ...discoveryDiagnostics.map((diagnostic) => ({ ...diagnostic, phase: "discovery" as const })),
38
+ ];
39
+ }
@@ -288,35 +288,57 @@ export async function handleRunControlCommand(
288
288
  } else if (action === "resume") {
289
289
  const backend = getDurableBackend();
290
290
  const exactBeforePreparation = store.runs().find((run) => run.id === target);
291
- let durable: readonly ResumableWorkflowEntry[] = [];
292
- let preparationError: string | undefined;
293
- const needsDurablePreparation = exactBeforePreparation === undefined
294
- || !backend.isWorkflowLoadable(exactBeforePreparation.id);
295
- if (needsDurablePreparation) {
296
- await ensureWorkflowResourcesVisible();
297
- const runtime = deps.runtimeForContext(ctx);
298
- try {
299
- durable = await runtime.prepareDurableResumable(target);
300
- } catch (error) {
301
- preparationError = error instanceof Error ? error.message : String(error);
302
- }
303
- }
304
- const loadableRuns = topLevelWorkflowRuns(store.runs()).filter((run) => backend.isWorkflowLoadable(run.id));
305
- const combined = resolveWorkflowResumeTarget(target, loadableRuns, durable, backend.listCompletedWorkflows());
306
- if (combined.kind === "ambiguous") {
307
- fail(`Ambiguous workflow prefix "${target}" matches: ${combined.matches.map((match) => `${match.name} (${match.workflowId.slice(0, 8)})`).join(", ")}`);
291
+ const exactHasPausedState = exactBeforePreparation?.status === "paused"
292
+ || (exactBeforePreparation?.stages.some((stage) => stage.status === "paused") ?? false);
293
+ const exactIsActivelyRunning = exactBeforePreparation !== undefined
294
+ && exactBeforePreparation.endedAt === undefined
295
+ && exactBeforePreparation.status === "running"
296
+ && !exactHasPausedState
297
+ && exactBeforePreparation.exitReason !== "quit";
298
+ if (exactIsActivelyRunning) {
299
+ fail(`Workflow ${exactBeforePreparation.id.slice(0, 8)} is already running in this session. Attach with \`/workflow connect ${exactBeforePreparation.id.slice(0, 8)}\` instead of resuming.`);
308
300
  return true;
309
301
  }
310
- if (combined.kind === "completed" || combined.kind === "durable") {
311
- return await handleDurableResume(combined.workflowId, ctx, reporter, deps);
312
- }
313
- if (combined.kind === "live") runId = combined.workflowId;
314
- else {
315
- if (preparationError !== undefined) {
316
- fail(`Failed to resolve workflow resume target: ${preparationError}`);
302
+ if (exactBeforePreparation !== undefined
303
+ && exactBeforePreparation.parentRunId === undefined
304
+ && exactHasPausedState
305
+ && backend.isWorkflowLoadable(exactBeforePreparation.id)) {
306
+ // Exact top-level live state is authoritative. Avoid scanning the
307
+ // potentially large completed catalog while preserving the established
308
+ // top-level target namespace and live-over-durable precedence.
309
+ runId = exactBeforePreparation.id;
310
+ } else {
311
+ let durable: readonly ResumableWorkflowEntry[] = [];
312
+ let preparationError: string | undefined;
313
+ const needsDurablePreparation = exactBeforePreparation === undefined
314
+ || (!backend.isWorkflowLoadable(exactBeforePreparation.id)
315
+ && (!exactHasPausedState || backend.hydrateResumableWorkflows !== undefined));
316
+ if (needsDurablePreparation) {
317
+ await ensureWorkflowResourcesVisible();
318
+ const runtime = deps.runtimeForContext(ctx);
319
+ try {
320
+ durable = await runtime.prepareDurableResumable(target);
321
+ } catch (error) {
322
+ preparationError = error instanceof Error ? error.message : String(error);
323
+ }
324
+ }
325
+ const loadableRuns = topLevelWorkflowRuns(store.runs()).filter((run) => backend.isWorkflowLoadable(run.id));
326
+ const combined = resolveWorkflowResumeTarget(target, loadableRuns, durable, backend.listCompletedWorkflows());
327
+ if (combined.kind === "ambiguous") {
328
+ fail(`Ambiguous workflow prefix "${target}" matches: ${combined.matches.map((match) => `${match.name} (${match.workflowId.slice(0, 8)})`).join(", ")}`);
317
329
  return true;
318
330
  }
319
- return await handleDurableResume(target, ctx, reporter, deps);
331
+ if (combined.kind === "completed" || combined.kind === "durable") {
332
+ return await handleDurableResume(combined.workflowId, ctx, reporter, deps);
333
+ }
334
+ if (combined.kind === "live") runId = combined.workflowId;
335
+ else {
336
+ if (preparationError !== undefined) {
337
+ fail(`Failed to resolve workflow resume target: ${preparationError}`);
338
+ return true;
339
+ }
340
+ return await handleDurableResume(target, ctx, reporter, deps);
341
+ }
320
342
  }
321
343
  } else {
322
344
  const resolved = resolveRunIdPrefix(target);
@@ -41,23 +41,13 @@ export function topLevelExpandedSnapshots() {
41
41
  }));
42
42
  }
43
43
 
44
- export function reloadBlockedMessage(count = inFlightRunCount()): string {
45
- return `Reload skipped: ${count} workflow run(s) still in flight. Wait for them to finish, or pause/kill them before reloading workflow resources.`;
46
- }
47
44
 
48
45
  export function allStageConflictMessage(action: "pause" | "interrupt" | "kill"): string {
49
46
  return `Cannot ${action} --all with a stageId; omit stageId or target a single run.`;
50
47
  }
51
48
 
52
- export class WorkflowReloadBlockedError extends Error {
53
- constructor(message: string) {
54
- super(message);
55
- this.name = "WorkflowReloadBlockedError";
56
- }
57
- }
58
49
 
59
50
  export function reloadFailureMessage(error: unknown): string {
60
- if (error instanceof WorkflowReloadBlockedError) return error.message;
61
51
  return `Reload failed: ${error instanceof Error ? error.message : String(error)}`;
62
52
  }
63
53