@desplega.ai/agent-swarm 2.5.2 → 2.5.3

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 (143) hide show
  1. package/dist/{acp-adapter-csh0zz3b.js → acp-adapter-gr2fcyyj.js} +3 -3
  2. package/dist/{actions-6xx079d1.js → actions-wv766ghf.js} +18 -18
  3. package/dist/{amp-adapter-8n6f0hz8.js → amp-adapter-speb906t.js} +3 -3
  4. package/dist/{app-ft6r6v23.js → app-ag29n14f.js} +3 -3
  5. package/dist/{assistant-vt2cyfzd.js → assistant-3x8bz3eg.js} +19 -19
  6. package/dist/{boot-reembed-r3e808ky.js → boot-reembed-5b3d2eea.js} +1 -1
  7. package/dist/{boot-reembed-tgrfy27t.js → boot-reembed-jsmfrpzf.js} +1 -1
  8. package/dist/{boot-scrub-sweep-masdwxkm.js → boot-scrub-sweep-s73gerxy.js} +2 -2
  9. package/dist/{boot-seeds-0n82qc7q.js → boot-seeds-5zmax2wr.js} +4 -4
  10. package/dist/{claude-adapter-s7j47k06.js → claude-adapter-hswdnm3t.js} +3 -3
  11. package/dist/{claude-managed-adapter-gt6pa35d.js → claude-managed-adapter-cvbzj1h6.js} +2 -2
  12. package/dist/{claude-managed-setup-3hzrgrnk.js → claude-managed-setup-3k8m3hhc.js} +2 -2
  13. package/dist/{claude-sdk-session-0zrdzyx2.js → claude-sdk-session-fkbyk66x.js} +2 -2
  14. package/dist/{cli-d172svcw.js → cli-1fc85mvt.js} +4 -4
  15. package/dist/{cli-3e9vs93g.js → cli-210xd1f4.js} +4 -4
  16. package/dist/{cli-vaxw3bg0.js → cli-25j01dpj.js} +3 -3
  17. package/dist/{cli-n6kmqngw.js → cli-35gtk1p6.js} +1 -1
  18. package/dist/{cli-fgn7yg5r.js → cli-3ngdk5te.js} +1 -1
  19. package/dist/{cli-7ccf7akh.js → cli-40jsnrjm.js} +2 -2
  20. package/dist/{cli-b1tv22wr.js → cli-502eqv93.js} +1 -1
  21. package/dist/{cli-ydg6ep51.js → cli-6n8axjv1.js} +1 -1
  22. package/dist/{cli-f55b6ky0.js → cli-8s5wx3nz.js} +2 -2
  23. package/dist/{cli-86zpfje5.js → cli-99xgw26r.js} +1 -1
  24. package/dist/{cli-xw9q173h.js → cli-9s05422w.js} +1 -1
  25. package/dist/{cli-h5d22pfs.js → cli-aecdcr77.js} +119 -282
  26. package/dist/{cli-w9etnnxk.js → cli-bf8ma3f1.js} +120 -50
  27. package/dist/{cli-15xd0j7w.js → cli-ce0ak9ms.js} +11 -11
  28. package/dist/{cli-y3dzwyy3.js → cli-e2ar4bp1.js} +71 -20
  29. package/dist/{cli-b6tnjdvx.js → cli-e8jyq7d8.js} +1 -1
  30. package/dist/{cli-6y43v79h.js → cli-egjggehm.js} +1 -1
  31. package/dist/{cli-cf63dnxj.js → cli-ewm3pwbq.js} +2 -2
  32. package/dist/{cli-bpbcpd8b.js → cli-fsdprwrg.js} +1 -1
  33. package/dist/{cli-qrjv7t18.js → cli-fzcn3d87.js} +1 -1
  34. package/dist/{cli-14v1sxxy.js → cli-gpsz7g88.js} +6 -6
  35. package/dist/{cli-wadzapwb.js → cli-hcpj2xqz.js} +1 -1
  36. package/dist/{cli-jmcjbjkt.js → cli-hhxjnjzs.js} +1 -1
  37. package/dist/{cli-j3dwd4kz.js → cli-jjznk3f7.js} +718 -108
  38. package/dist/{cli-csh8fnbg.js → cli-kxawjrxg.js} +147 -28
  39. package/dist/{cli-mntxtkhn.js → cli-mz9afxxf.js} +2 -1
  40. package/dist/{cli-0vgy5wfw.js → cli-n45mr7zh.js} +2 -2
  41. package/dist/{cli-aaqnqpyw.js → cli-p0rzjkya.js} +2 -2
  42. package/dist/{cli-3qv8naq8.js → cli-p6zs9h4y.js} +827 -107
  43. package/dist/{cli-e91vshb1.js → cli-p9rafdrb.js} +4 -4
  44. package/dist/{cli-edtm741g.js → cli-qgf8emrn.js} +5 -5
  45. package/dist/{cli-ha22h0eg.js → cli-qzme5949.js} +26 -26
  46. package/dist/{cli-xzge46sj.js → cli-s49hpn37.js} +2 -2
  47. package/dist/{cli-b63d08rs.js → cli-s53vp709.js} +1 -1
  48. package/dist/{cli-cyfgxnvb.js → cli-svrj71pg.js} +18 -16
  49. package/dist/{cli-4qkg9968.js → cli-vwn81jbs.js} +3 -3
  50. package/dist/{cli-f3pfn4x3.js → cli-w1zwctj2.js} +1 -1
  51. package/dist/{cli-kcnwn85g.js → cli-y561h5a9.js} +4 -4
  52. package/dist/{cli-n2918sed.js → cli-z21whrrg.js} +15 -15
  53. package/dist/cli.js +16 -16
  54. package/dist/{codex-adapter-brh2tcxr.js → codex-adapter-n8ka66vf.js} +2 -2
  55. package/dist/{codex-session-runner-0dnj21qs.js → codex-session-runner-fsebts37.js} +1 -1
  56. package/dist/{commands-1cnvseqx.js → commands-t04rt2pc.js} +2 -2
  57. package/dist/{controller-kc7s9xhn.js → controller-rgna04r2.js} +2 -2
  58. package/dist/{controllers-connect-jzx7t4bn.js → controllers-connect-bqm1rtft.js} +20 -20
  59. package/dist/{cursor-adapter-qpwp5sfg.js → cursor-adapter-swr2k95y.js} +1 -1
  60. package/dist/{db-v5ytg5pw.js → db-24ck09xy.js} +1 -1
  61. package/dist/{db-4vq88jkc.js → db-a4m7haw0.js} +3 -3
  62. package/dist/{dispatcher-trtcrqae.js → dispatcher-nwf9ss0q.js} +17 -17
  63. package/dist/{dsh-adapter-f20xwn1z.js → dsh-adapter-kpdnhjst.js} +2 -2
  64. package/dist/{external-channel-n9bymc2n.js → external-channel-j1e2p34p.js} +2 -2
  65. package/dist/{grok-adapter-4kpmmnr6.js → grok-adapter-qtjz0whk.js} +4 -4
  66. package/dist/{handlers-ptsraqa8.js → handlers-19xr6dnz.js} +20 -20
  67. package/dist/{heartbeat-60qtxtjf.js → heartbeat-vtesda5d.js} +20 -20
  68. package/dist/{hook-rbktcstg.js → hook-8wqaszda.js} +1 -1
  69. package/dist/{hook-ngtvh729.js → hook-b2hhj6rd.js} +1 -1
  70. package/dist/{http-z8xy72gp.js → http-mxcqhhcs.js} +2757 -2448
  71. package/dist/{index-a9nsavhx.js → index-1r8wfpzs.js} +1 -1
  72. package/dist/{index-cbxkmfc5.js → index-9pn1pfkf.js} +1 -1
  73. package/dist/{index-s193j5fh.js → index-kw2mmat7.js} +20 -20
  74. package/dist/{index-06qkmcx6.js → index-n1vbvta9.js} +17 -9
  75. package/dist/{index-6srwvd5b.js → index-p4xfz37z.js} +1 -1
  76. package/dist/{index-1cscynkb.js → index-spwxjgay.js} +17 -17
  77. package/dist/{index-r70q0xjx.js → index-srg522m6.js} +17 -17
  78. package/dist/{keepalive-a9srcphj.js → keepalive-dsd8rtgx.js} +3 -3
  79. package/dist/{lead-3devneqy.js → lead-zyerk1x2.js} +30 -30
  80. package/dist/{maintenance-0er16h2y.js → maintenance-jqmqwnet.js} +1 -1
  81. package/dist/{oauth-refresh-sweep-b65jcyw2.js → oauth-refresh-sweep-q2c3bnvm.js} +2 -2
  82. package/dist/{onboard-wqywz5py.js → onboard-w4yd7s8t.js} +2 -2
  83. package/dist/{opencode-adapter-e56086m1.js → opencode-adapter-9tppvxar.js} +2 -2
  84. package/dist/{otel-impl-vk32s884.js → otel-impl-f36byzbd.js} +1 -1
  85. package/dist/{pricing-refresh-t27anv4z.js → pricing-refresh-xvak5p4v.js} +3 -3
  86. package/dist/{rbac-roles-bmcgsxjn.js → rbac-roles-682k2mt5.js} +1 -1
  87. package/dist/{rbac-roles-452665y0.js → rbac-roles-gfn30fb8.js} +2 -2
  88. package/dist/{render-v2-0ttsk2j4.js → render-v2-hgytz8nn.js} +3 -3
  89. package/dist/{resume-a26kvr41.js → resume-2dvq348g.js} +7 -5
  90. package/dist/{session-access-dgh9nkwk.js → session-access-7f21ekq9.js} +4 -4
  91. package/dist/{setup-9vc9qahm.js → setup-epffxtbk.js} +1 -1
  92. package/dist/{work-objects-vn8nkxwn.js → work-objects-xz1a5rqk.js} +20 -20
  93. package/dist/{worker-1x1h8vnm.js → worker-42t8emjt.js} +30 -30
  94. package/openapi.json +3045 -2456
  95. package/package.json +4 -4
  96. package/src/be/approval-config-question.ts +389 -0
  97. package/src/be/automation-preflight.ts +37 -3
  98. package/src/be/db/session-tree.ts +73 -15
  99. package/src/be/db/skill-search.ts +87 -0
  100. package/src/be/db/tasks/read.ts +29 -2
  101. package/src/be/db/workflow-runs.ts +54 -7
  102. package/src/be/db.ts +25 -5
  103. package/src/be/migrations/239_skills_fts.sql +40 -0
  104. package/src/be/migrations/240_workflow_run_parent_step_attempt.sql +37 -0
  105. package/src/be/migrations/241_skills_meta_fts.sql +39 -0
  106. package/src/be/swarm-config-guard.ts +23 -0
  107. package/src/extensions/catalog.generated.json +4 -4
  108. package/src/http/approval-requests.ts +209 -38
  109. package/src/http/config.ts +22 -37
  110. package/src/http/sessions.ts +27 -2
  111. package/src/integrations/chat-sdk/inbound.ts +5 -4
  112. package/src/providers/amp-adapter.ts +1 -1
  113. package/src/providers/dsh-adapter.ts +1 -1
  114. package/src/tests/approval-config-question.test.ts +702 -0
  115. package/src/tests/chat-sdk-inbound.test.ts +20 -0
  116. package/src/tests/dsh-adapter.test.ts +1 -1
  117. package/src/tests/entrypoint-gitlab-credential-helper.test.ts +273 -0
  118. package/src/tests/extensions-example-comms-adherence-gate.test.ts +321 -12
  119. package/src/tests/list-endpoint-slimming.test.ts +33 -0
  120. package/src/tests/migration-240-workflow-run-parent-step-attempt.test.ts +55 -0
  121. package/src/tests/script-connections.test.ts +6 -0
  122. package/src/tests/sessions-tree.test.ts +168 -1
  123. package/src/tests/skill-search-long-content.test.ts +193 -0
  124. package/src/tests/skill-search.test.ts +173 -22
  125. package/src/tests/workflow-sub-workflow.test.ts +538 -1
  126. package/src/tools/request-human-input.ts +27 -5
  127. package/src/types.ts +7 -0
  128. package/src/workflows/approval-notifications.ts +10 -3
  129. package/src/workflows/checkpoint.ts +69 -35
  130. package/src/workflows/engine.ts +234 -87
  131. package/src/workflows/executors/base.ts +6 -0
  132. package/src/workflows/executors/human-in-the-loop.ts +25 -1
  133. package/src/workflows/executors/sub-workflow.ts +45 -16
  134. package/src/workflows/recovery.ts +63 -1
  135. package/src/workflows/resume.ts +60 -21
  136. package/src/workflows/retry-poller.ts +25 -4
  137. package/templates/extensions/comms-adherence-gate/README.md +38 -13
  138. package/templates/extensions/comms-adherence-gate/hooks.ts +272 -69
  139. package/templates/extensions/comms-adherence-gate/manifest.json +2 -2
  140. package/templates/skills/turso-interaction/content.md +1 -1
  141. package/templates/skills/workflow-iterate/content.md +8 -1
  142. /package/dist/{cli-stxdrqs2.js → cli-avdvgcay.js} +0 -0
  143. /package/dist/{pi-mono-adapter-tzps8f38.js → pi-mono-adapter-tqsge1ay.js} +0 -0
@@ -1,8 +1,30 @@
1
1
  import { getDbClient, getWorkflowRun, updateWorkflowRun, updateWorkflowRunStep } from "../be/db";
2
2
  import type { RetryPolicy } from "../types";
3
3
 
4
+ /**
5
+ * The execution a checkpoint belongs to: the step attempt (`retryCount`) that
6
+ * claimed the row. A fenced checkpoint writes only while that attempt still
7
+ * owns the row: the step is `running` with that `retryCount` in a `running` or
8
+ * `waiting` run. A cancel, a newer attempt or another settle that committed
9
+ * meanwhile wins, and the stale execution writes nothing.
10
+ */
11
+ export type StepFence = { retryCount: number };
12
+
13
+ async function holdsFence(runId: string, stepId: string, fence: StepFence): Promise<boolean> {
14
+ const row = await getDbClient().get<{ found: number }>(
15
+ `SELECT 1 AS found
16
+ FROM workflow_run_steps s
17
+ JOIN workflow_runs r ON r.id = s.runId
18
+ WHERE s.id = ? AND s.runId = ? AND s.status = 'running' AND s.retryCount = ?
19
+ AND r.status IN ('running', 'waiting')`,
20
+ [stepId, runId, fence.retryCount],
21
+ );
22
+ return row != null;
23
+ }
24
+
4
25
  /**
5
26
  * Checkpoint a successful step — atomic DB write of step result + run context.
27
+ * Returns false, writing nothing, when `fence` no longer holds.
6
28
  */
7
29
  export async function checkpointStep(
8
30
  runId: string,
@@ -10,8 +32,10 @@ export async function checkpointStep(
10
32
  nodeId: string,
11
33
  result: { output?: unknown; nextPort?: string },
12
34
  ctx: Record<string, unknown>,
13
- ): Promise<void> {
14
- await getDbClient().transaction(async () => {
35
+ options: { fence?: StepFence } = {},
36
+ ): Promise<boolean> {
37
+ return getDbClient().transaction(async () => {
38
+ if (options.fence && !(await holdsFence(runId, stepId, options.fence))) return false;
15
39
  await updateWorkflowRunStep(stepId, {
16
40
  status: "completed",
17
41
  output: result.output,
@@ -24,11 +48,14 @@ export async function checkpointStep(
24
48
  await updateWorkflowRun(runId, {
25
49
  context: ctx,
26
50
  });
51
+ return true;
27
52
  });
28
53
  }
29
54
 
30
55
  /**
31
56
  * Checkpoint a step failure — marks step failed, calculates retry if applicable.
57
+ * With `fence`, a stale execution writes nothing and gets `stale: true`.
58
+ * `output` is kept on the failed step for observability, in the same write.
32
59
  */
33
60
  export async function checkpointStepFailure(
34
61
  runId: string,
@@ -36,49 +63,56 @@ export async function checkpointStepFailure(
36
63
  error: string,
37
64
  retryCount: number,
38
65
  retryPolicy?: RetryPolicy,
39
- options?: { markRunFailed?: boolean },
40
- ): Promise<{ shouldRetry: boolean }> {
66
+ options?: { markRunFailed?: boolean; fence?: StepFence; output?: unknown },
67
+ ): Promise<{ shouldRetry: boolean; stale?: true }> {
41
68
  const now = new Date().toISOString();
42
69
 
43
- if (retryPolicy && retryCount < retryPolicy.maxRetries) {
44
- const delay = calculateBackoff(retryPolicy, retryCount);
45
- const nextRetryAt = new Date(Date.now() + delay).toISOString();
70
+ return getDbClient().transaction(async () => {
71
+ if (options?.fence && !(await holdsFence(runId, stepId, options.fence))) {
72
+ return { shouldRetry: false, stale: true as const };
73
+ }
74
+
75
+ if (retryPolicy && retryCount < retryPolicy.maxRetries) {
76
+ const delay = calculateBackoff(retryPolicy, retryCount);
77
+ const nextRetryAt = new Date(Date.now() + delay).toISOString();
78
+
79
+ await updateWorkflowRunStep(stepId, {
80
+ status: "failed",
81
+ error,
82
+ output: options?.output,
83
+ retryCount: retryCount + 1,
84
+ maxRetries: retryPolicy.maxRetries,
85
+ nextRetryAt,
86
+ });
87
+
88
+ return { shouldRetry: true };
89
+ }
46
90
 
91
+ // No retries left — mark step failed, and optionally the run too
92
+ // Clear nextRetryAt so the poller stops picking this step up
47
93
  await updateWorkflowRunStep(stepId, {
48
94
  status: "failed",
49
95
  error,
50
- retryCount: retryCount + 1,
51
- maxRetries: retryPolicy.maxRetries,
52
- nextRetryAt,
96
+ output: options?.output,
97
+ finishedAt: now,
98
+ nextRetryAt: null,
53
99
  });
54
100
 
55
- return { shouldRetry: true };
56
- }
57
-
58
- // No retries left — mark step failed, and optionally the run too
59
- // Clear nextRetryAt so the poller stops picking this step up
60
- await updateWorkflowRunStep(stepId, {
61
- status: "failed",
62
- error,
63
- finishedAt: now,
64
- nextRetryAt: null,
65
- });
66
-
67
- const markRunFailed = options?.markRunFailed ?? true;
68
- if (markRunFailed) {
69
- // A cancel or finalization that committed while the step ran wins.
70
- await getDbClient().transaction(async () => {
101
+ const markRunFailed = options?.markRunFailed ?? true;
102
+ if (markRunFailed) {
103
+ // A cancel or finalization that committed while the step ran wins.
71
104
  const run = await getWorkflowRun(runId);
72
- if (run?.status !== "running" && run?.status !== "waiting") return;
73
- await updateWorkflowRun(runId, {
74
- status: "failed",
75
- error: `Step failed: ${error}`,
76
- finishedAt: now,
77
- });
78
- });
79
- }
105
+ if (run?.status === "running" || run?.status === "waiting") {
106
+ await updateWorkflowRun(runId, {
107
+ status: "failed",
108
+ error: `Step failed: ${error}`,
109
+ finishedAt: now,
110
+ });
111
+ }
112
+ }
80
113
 
81
- return { shouldRetry: false };
114
+ return { shouldRetry: false };
115
+ });
82
116
  }
83
117
 
84
118
  /**
@@ -1,6 +1,7 @@
1
1
  import { notifyAutomationPreflightFailure } from "../automation-preflight-alert";
2
2
  import {
3
3
  getAutomationSetupStates,
4
+ isFirstWorkflowPreflightFailureToday,
4
5
  preflightAutomation,
5
6
  recordWorkflowPreflightFailure,
6
7
  renderAutomationTokens,
@@ -20,10 +21,16 @@ import {
20
21
  updateWorkflowRun,
21
22
  updateWorkflowRunStep,
22
23
  } from "../be/db";
24
+ import { getChildWorkflowRunId, isParentStepAttemptCurrent } from "../be/db/workflow-runs";
23
25
  import { WorkflowReplayStateError } from "../be/workflow-replay";
24
26
  import { telemetry } from "../telemetry";
25
27
  import type { Workflow, WorkflowDefinition, WorkflowNode, WorkflowRunStep } from "../types";
26
- import { checkpointStep, checkpointStepFailure, checkpointStepWaiting } from "./checkpoint";
28
+ import {
29
+ checkpointStep,
30
+ checkpointStepFailure,
31
+ checkpointStepWaiting,
32
+ type StepFence,
33
+ } from "./checkpoint";
27
34
  import { loadCompletedStepRouting } from "./completed-step-routing";
28
35
  import { FOREACH_ITEMS_WAIT_FOR } from "./constants";
29
36
  import { shouldSkipCooldown } from "./cooldown";
@@ -58,6 +65,8 @@ export interface WorkflowExecutionOptions {
58
65
  triggerType?: "schedule" | "manual" | "event" | "api";
59
66
  /** Set when a `sub-workflow` step starts this run as its child. */
60
67
  parentStepId?: string;
68
+ /** The parent step's `retryCount`; each attempt gets its own child run. */
69
+ parentStepAttempt?: number;
61
70
  }
62
71
 
63
72
  /**
@@ -87,11 +96,9 @@ async function resolveRenderedWorkflowInputs(
87
96
  /**
88
97
  * Start executing a workflow from scratch.
89
98
  *
90
- * 1. Check cooldown
91
- * 2. Create workflow run
92
- * 3. Resolve inputs
93
- * 4. Find entry nodes
94
- * 5. Walk the graph
99
+ * 1. Check cooldown, readiness and inputs
100
+ * 2. Create the workflow run with its initial state (one transaction)
101
+ * 3. Walk the graph from the entry nodes
95
102
  */
96
103
  export async function startWorkflowExecution(
97
104
  workflow: Workflow,
@@ -103,7 +110,7 @@ export async function startWorkflowExecution(
103
110
  workflowPreflightInput(workflow),
104
111
  await getAutomationSetupStates(),
105
112
  );
106
- if (preflight.state === "needs_setup") {
113
+ if (preflight.state === "needs_setup" && !options.parentStepId) {
107
114
  const { runId, recorded } = await recordWorkflowPreflightFailure({
108
115
  workflowId: workflow.id,
109
116
  triggerType: options.triggerType ?? "manual",
@@ -115,63 +122,126 @@ export async function startWorkflowExecution(
115
122
  return runId;
116
123
  }
117
124
 
118
- // Templates can consume install params outside the graph definition (most
119
- // importantly workflow.input). Render the complete runtime snapshot once;
120
- // exact-token values retain their JSON type, including COMPETITORS arrays.
121
- workflow = renderAutomationTokens(workflow, workflow.params ?? {});
125
+ // Settle everything the run starts from before its row exists, so the row
126
+ // commits together with its initial context or terminal status. A crash
127
+ // right after the commit leaves a run that recovery can walk, never a
128
+ // `running` row with an empty context.
129
+ let initial: InitialRunState;
130
+ if (preflight.state === "needs_setup") {
131
+ // A child keeps its parent link and attempt even when it cannot start:
132
+ // the day-deduped top-level failure run is shared and unlinked, so a
133
+ // retry or recovery of the step would never find it.
134
+ initial = {
135
+ status: "failed",
136
+ error: preflight.failureReason!,
137
+ finishedAt: new Date().toISOString(),
138
+ preflight: true,
139
+ };
140
+ } else {
141
+ // Templates can consume install params outside the graph definition (most
142
+ // importantly workflow.input). Render the complete runtime snapshot once;
143
+ // exact-token values retain their JSON type, including COMPETITORS arrays.
144
+ workflow = renderAutomationTokens(workflow, workflow.params ?? {});
145
+
146
+ // Validate trigger data against triggerSchema (before any DB writes)
147
+ if (workflow.triggerSchema) {
148
+ const validationErrors = validateJsonSchema(workflow.triggerSchema, triggerData);
149
+ if (validationErrors.length > 0) {
150
+ throw new TriggerSchemaError(validationErrors);
151
+ }
152
+ }
122
153
 
123
- // Validate trigger data against triggerSchema (before any DB writes)
124
- if (workflow.triggerSchema) {
125
- const validationErrors = validateJsonSchema(workflow.triggerSchema, triggerData);
126
- if (validationErrors.length > 0) {
127
- throw new TriggerSchemaError(validationErrors);
154
+ if (workflow.cooldown && (await shouldSkipCooldown(workflow.id, workflow.cooldown))) {
155
+ initial = { status: "skipped", error: "cooldown", finishedAt: new Date().toISOString() };
156
+ } else {
157
+ initial = await buildInitialRunState(workflow, triggerData, registry, options);
128
158
  }
129
159
  }
130
160
 
131
- // Cooldown check
132
- if (workflow.cooldown && (await shouldSkipCooldown(workflow.id, workflow.cooldown))) {
133
- const runId = crypto.randomUUID();
134
- await createWorkflowRun({
135
- id: runId,
161
+ // Held from before the commit until the walk holds the run itself, so a
162
+ // recovery sweep never walks the new run alongside it.
163
+ const newRunId = crypto.randomUUID();
164
+ const release = holdWorkflowRun(newRunId);
165
+ try {
166
+ const { runId, created, preflightAlert } = await createRunWithInitialState(
167
+ workflow,
168
+ triggerData,
169
+ options,
170
+ initial,
171
+ newRunId,
172
+ );
173
+ if (!created) return runId;
174
+ if (initial.status === "failed" && initial.preflight) {
175
+ if (preflightAlert) await notifyAutomationPreflightFailure(preflight);
176
+ return runId;
177
+ }
178
+ if (initial.status === "skipped") return runId;
179
+ telemetry.workflow("started", {
136
180
  workflowId: workflow.id,
181
+ nodeCount: workflow.definition.nodes.length,
137
182
  triggerType: options.triggerType ?? "manual",
138
- triggerData,
139
- createdBy: options.requestedByUserId,
140
- parentStepId: options.parentStepId,
141
- });
142
- await updateWorkflowRun(runId, {
143
- status: "skipped",
144
- error: "cooldown",
145
- finishedAt: new Date().toISOString(),
146
183
  });
184
+ if (initial.status) return runId;
185
+
186
+ const walk = walkGraph(
187
+ workflow.definition,
188
+ runId,
189
+ initial.context,
190
+ findEntryNodes(workflow.definition),
191
+ registry,
192
+ workflow.id,
193
+ getSecretInputKeys(workflow.input),
194
+ options,
195
+ );
196
+ // A child run walks on its own, outside the parent executor's timeout. Its
197
+ // terminal status wakes the parent step (`resumeFromChildRun`).
198
+ if (options.parentStepId) {
199
+ walk.catch((err) => console.error(`[workflows] Child workflow run ${runId} failed:`, err));
200
+ return runId;
201
+ }
202
+ await walk;
147
203
  return runId;
204
+ } finally {
205
+ release();
148
206
  }
207
+ }
149
208
 
150
- const runId = crypto.randomUUID();
151
- await createWorkflowRun({
152
- id: runId,
153
- workflowId: workflow.id,
154
- triggerType: options.triggerType ?? "manual",
155
- triggerData,
156
- createdBy: options.requestedByUserId,
157
- parentStepId: options.parentStepId,
158
- });
159
- telemetry.workflow("started", {
160
- workflowId: workflow.id,
161
- nodeCount: workflow.definition.nodes.length,
162
- triggerType: options.triggerType ?? "manual",
163
- });
209
+ /**
210
+ * Thrown when a `sub-workflow` step asks for a child run while the step's
211
+ * attempt no longer executes (cancelled, re-attempted or already parked).
212
+ */
213
+ export class ParentStepNotCurrentError extends Error {
214
+ constructor(parentStepId: string, attempt: number) {
215
+ super(
216
+ `Parent step ${parentStepId} (attempt ${attempt}) is no longer running; no child run started`,
217
+ );
218
+ this.name = "ParentStepNotCurrentError";
219
+ }
220
+ }
164
221
 
222
+ /**
223
+ * A new run's first committed state: a terminal status, or the context it
224
+ * walks from. `preflight` marks a setup failure, which alerts once a day.
225
+ */
226
+ type InitialRunState =
227
+ | { status: "skipped" | "failed"; error: string; finishedAt: string; preflight?: true }
228
+ | { status?: undefined; context: Record<string, unknown> };
229
+
230
+ async function buildInitialRunState(
231
+ workflow: Workflow,
232
+ triggerData: unknown,
233
+ registry: ExecutorRegistry,
234
+ options: WorkflowExecutionOptions,
235
+ ): Promise<InitialRunState> {
165
236
  // An executor that cannot run (for example a system-one-decision node with no API key) fails
166
237
  // the run here, before any node has side effects, instead of partway through.
167
238
  const notReady = await findWorkflowReadinessProblems(workflow.definition, registry);
168
239
  if (notReady.length > 0) {
169
- await updateWorkflowRun(runId, {
240
+ return {
170
241
  status: "failed",
171
242
  error: formatReadinessRunError(notReady),
172
243
  finishedAt: new Date().toISOString(),
173
- });
174
- return runId;
244
+ };
175
245
  }
176
246
 
177
247
  // Resolve inputs and merge into initial context
@@ -190,35 +260,62 @@ export async function startWorkflowExecution(
190
260
  const resolved = await resolveRenderedWorkflowInputs(workflow.input);
191
261
  Object.assign(ctx, { input: resolved });
192
262
  } catch (err) {
193
- await updateWorkflowRun(runId, {
263
+ return {
194
264
  status: "failed",
195
265
  error: `Input resolution failed: ${err}`,
196
266
  finishedAt: new Date().toISOString(),
197
- });
198
- return runId;
267
+ };
199
268
  }
200
269
  }
270
+ return { context: ctx };
271
+ }
201
272
 
202
- const entryNodes = findEntryNodes(workflow.definition);
203
- const secretKeys = getSecretInputKeys(workflow.input);
204
- const walk = walkGraph(
205
- workflow.definition,
206
- runId,
207
- ctx,
208
- entryNodes,
209
- registry,
210
- workflow.id,
211
- secretKeys,
212
- options,
213
- );
214
- // A child run walks on its own, outside the parent executor's timeout. Its
215
- // terminal status wakes the parent step (`resumeFromChildRun`).
216
- if (options.parentStepId) {
217
- walk.catch((err) => console.error(`[workflows] Child workflow run ${runId} failed:`, err));
218
- return runId;
219
- }
220
- await walk;
221
- return runId;
273
+ /**
274
+ * Insert the run with its initial state in one transaction. For a child run
275
+ * the same transaction checks that the parent step's attempt is still
276
+ * executing and returns the child that attempt already started instead of a
277
+ * second one; the unique (parentStepId, parentStepAttempt) index backs this
278
+ * across processes. A setup failure claims the day's alert in the same
279
+ * transaction, so concurrent failures alert exactly once.
280
+ */
281
+ async function createRunWithInitialState(
282
+ workflow: Workflow,
283
+ triggerData: unknown,
284
+ options: WorkflowExecutionOptions,
285
+ initial: InitialRunState,
286
+ runId: string,
287
+ ): Promise<{ runId: string; created: boolean; preflightAlert?: boolean }> {
288
+ return getDbClient().transaction(async () => {
289
+ const { parentStepId } = options;
290
+ const attempt = options.parentStepAttempt ?? 0;
291
+ if (parentStepId) {
292
+ if (!(await isParentStepAttemptCurrent(parentStepId, attempt))) {
293
+ throw new ParentStepNotCurrentError(parentStepId, attempt);
294
+ }
295
+ const existingId = await getChildWorkflowRunId(parentStepId, attempt);
296
+ if (existingId) return { runId: existingId, created: false };
297
+ }
298
+ await createWorkflowRun({
299
+ id: runId,
300
+ workflowId: workflow.id,
301
+ triggerType: options.triggerType ?? "manual",
302
+ triggerData,
303
+ createdBy: options.requestedByUserId,
304
+ parentStepId,
305
+ parentStepAttempt: parentStepId ? attempt : undefined,
306
+ });
307
+ await updateWorkflowRun(
308
+ runId,
309
+ initial.status
310
+ ? { status: initial.status, error: initial.error, finishedAt: initial.finishedAt }
311
+ : { context: initial.context },
312
+ );
313
+ const preflightAlert =
314
+ initial.status === "failed" && initial.preflight
315
+ ? await isFirstWorkflowPreflightFailureToday(workflow.id, runId)
316
+ : undefined;
317
+ return { runId, created: true, preflightAlert };
318
+ });
222
319
  }
223
320
 
224
321
  // ─── Graph Walker ──────────────────────────────────────────
@@ -270,6 +367,11 @@ export function isWorkflowRunActive(runId: string): boolean {
270
367
  return activeWalks.has(runId);
271
368
  }
272
369
 
370
+ /** True while an executeStep call in this process owns the step row. */
371
+ export function isStepExecuting(stepId: string): boolean {
372
+ return executingSteps.has(stepId);
373
+ }
374
+
273
375
  /**
274
376
  * Mark the run as owned by this process until the returned release runs.
275
377
  * Recovery skips owned runs, so an executor outside walkGraph (the retry
@@ -705,6 +807,20 @@ async function executeStep(
705
807
  if (current && (current.status !== "running" || executingSteps.has(current.id))) {
706
808
  return { current };
707
809
  }
810
+ // An orphaned `sub-workflow` row is this same invocation: it covers the
811
+ // current predecessor completions, so it is not a new loop iteration.
812
+ // Re-enter it under its step id, key and retry count, so the executor
813
+ // reconnects to the child it already started instead of a second one.
814
+ if (current && node.type === "sub-workflow") {
815
+ claimedStepId = current.id;
816
+ executingSteps.add(current.id);
817
+ return {
818
+ existingStep: current,
819
+ stepId: current.id,
820
+ fence: { retryCount: current.retryCount },
821
+ deduped: false,
822
+ };
823
+ }
708
824
  }
709
825
 
710
826
  // Count existing steps for this node to determine the current iteration.
@@ -717,7 +833,12 @@ async function executeStep(
717
833
  existingStep &&
718
834
  (existingStep.status === "completed" || existingStep.status === "waiting")
719
835
  ) {
720
- return { existingStep, stepId: existingStep.id, deduped: true };
836
+ return {
837
+ existingStep,
838
+ stepId: existingStep.id,
839
+ fence: { retryCount: existingStep.retryCount },
840
+ deduped: true,
841
+ };
721
842
  }
722
843
  // For "pending" or "failed" steps, fall through to re-execute
723
844
 
@@ -748,7 +869,8 @@ async function executeStep(
748
869
  // this `running` row without also seeing it owned.
749
870
  claimedStepId = stepId;
750
871
  executingSteps.add(stepId);
751
- return { existingStep, stepId, deduped: false };
872
+ const fence = { retryCount: reusableForeachStep?.retryCount ?? 0 };
873
+ return { existingStep, stepId, fence, deduped: false };
752
874
  };
753
875
  const dedup = await getDbClient()
754
876
  .transaction(claimStep)
@@ -787,6 +909,7 @@ async function executeStep(
787
909
  registry,
788
910
  dedup.existingStep,
789
911
  dedup.stepId,
912
+ dedup.fence,
790
913
  workflowId,
791
914
  options,
792
915
  );
@@ -800,7 +923,11 @@ async function executeStep(
800
923
  }
801
924
  }
802
925
 
803
- /** Steps 3-10 of executeStep, for a step row this call inserted and owns. */
926
+ /**
927
+ * Steps 3-10 of executeStep, for a step row this call claimed. Every result
928
+ * write is fenced on the claim: once a cancel, a newer attempt or another
929
+ * settle moved the row, this execution writes nothing and routes nothing.
930
+ */
804
931
  async function runClaimedStep(
805
932
  def: WorkflowDefinition,
806
933
  runId: string,
@@ -809,9 +936,11 @@ async function runClaimedStep(
809
936
  registry: ExecutorRegistry,
810
937
  existingStep: WorkflowRunStep | null | undefined,
811
938
  stepId: string,
939
+ fence: StepFence,
812
940
  workflowId?: string,
813
941
  options: WorkflowExecutionOptions = {},
814
942
  ): Promise<StepResult> {
943
+ const stale: StepResult = { outcome: "failed", successors: [] };
815
944
  // 3. Get executor
816
945
  const executor = registry.get(node.type);
817
946
 
@@ -820,7 +949,9 @@ async function runClaimedStep(
820
949
  // requests and multiply paid attempts. Fail before any request is built.
821
950
  const systemOneRetryProblems = systemOneRetryViolations(node);
822
951
  if (systemOneRetryProblems.length > 0) {
823
- await checkpointStepFailure(runId, stepId, systemOneRetryProblems.join("; "), 0);
952
+ await checkpointStepFailure(runId, stepId, systemOneRetryProblems.join("; "), 0, undefined, {
953
+ fence,
954
+ });
824
955
  return { outcome: "failed", successors: [] };
825
956
  }
826
957
 
@@ -835,7 +966,10 @@ async function runClaimedStep(
835
966
  );
836
967
  if (inputErrors.length > 0) {
837
968
  const errorMsg = `Input schema validation failed: ${inputErrors.join("; ")}`;
838
- await checkpointStepFailure(runId, stepId, errorMsg, 0);
969
+ const { stale: lost } = await checkpointStepFailure(runId, stepId, errorMsg, 0, undefined, {
970
+ fence,
971
+ });
972
+ if (lost) return stale;
839
973
  throw new Error(errorMsg);
840
974
  }
841
975
  }
@@ -852,7 +986,7 @@ async function runClaimedStep(
852
986
 
853
987
  if (scriptBodyUnresolved && scriptBodyUnresolved.length > 0) {
854
988
  const errorMsg = scriptBodyInterpolationError(node.id, scriptBodyUnresolved);
855
- await checkpointStepFailure(runId, stepId, errorMsg, 0);
989
+ await checkpointStepFailure(runId, stepId, errorMsg, 0, undefined, { fence });
856
990
  return { outcome: "failed", successors: [] };
857
991
  }
858
992
 
@@ -863,6 +997,8 @@ async function runClaimedStep(
863
997
  stepId,
864
998
  systemOneUnresolvedError(node.id, strictUnresolved, node.type),
865
999
  0,
1000
+ undefined,
1001
+ { fence },
866
1002
  );
867
1003
  return { outcome: "failed", successors: [] };
868
1004
  }
@@ -905,14 +1041,16 @@ async function runClaimedStep(
905
1041
  // Apply retry policy if configured
906
1042
  const retryPolicy = node.retry || executor.retryPolicy;
907
1043
  const currentRetryCount = existingStep?.retryCount || 0;
908
- const { shouldRetry } = await checkpointStepFailure(
1044
+ const { shouldRetry, stale: lost } = await checkpointStepFailure(
909
1045
  runId,
910
1046
  stepId,
911
1047
  errorMsg,
912
1048
  currentRetryCount,
913
1049
  retryPolicy,
1050
+ { fence },
914
1051
  );
915
1052
 
1053
+ if (lost) return stale;
916
1054
  if (!shouldRetry) {
917
1055
  throw err; // Will be caught by walkGraph
918
1056
  }
@@ -922,20 +1060,21 @@ async function runClaimedStep(
922
1060
 
923
1061
  // 6. Handle result
924
1062
  if (result.status === "failed") {
925
- const retryPolicy = node.retry || executor.retryPolicy;
1063
+ // A failure the executor marks non-retryable skips the policy here as on
1064
+ // every other path that settles the same outcome.
1065
+ const retryPolicy = result.retryable === false ? undefined : node.retry || executor.retryPolicy;
926
1066
  const currentRetryCount = existingStep?.retryCount || 0;
927
- const { shouldRetry } = await checkpointStepFailure(
1067
+ // Output is persisted for observability even on failure (e.g. script
1068
+ // nodes keep {exitCode, stdout, stderr}), in the same fenced write.
1069
+ const { shouldRetry, stale: lost } = await checkpointStepFailure(
928
1070
  runId,
929
1071
  stepId,
930
1072
  result.error || "Executor returned failed status",
931
1073
  currentRetryCount,
932
1074
  retryPolicy,
1075
+ { fence, output: result.output },
933
1076
  );
934
-
935
- // Persist output for observability even on failure (e.g. script nodes keep {exitCode, stdout, stderr})
936
- if (result.output !== undefined) {
937
- await updateWorkflowRunStep(stepId, { output: result.output });
938
- }
1077
+ if (lost) return stale;
939
1078
 
940
1079
  if (!shouldRetry) {
941
1080
  throw new Error(result.error || "Step execution failed");
@@ -976,7 +1115,10 @@ async function runClaimedStep(
976
1115
  );
977
1116
  if (outputErrors.length > 0) {
978
1117
  const errorMsg = `Output schema validation failed: ${outputErrors.join("; ")}`;
979
- await checkpointStepFailure(runId, stepId, errorMsg, 0);
1118
+ const { stale: lost } = await checkpointStepFailure(runId, stepId, errorMsg, 0, undefined, {
1119
+ fence,
1120
+ });
1121
+ if (lost) return stale;
980
1122
  throw new Error(errorMsg);
981
1123
  }
982
1124
  }
@@ -988,7 +1130,10 @@ async function runClaimedStep(
988
1130
 
989
1131
  if (validationResult.outcome === "halt") {
990
1132
  const errorMsg = "Validation failed (mustPass)";
991
- await checkpointStepFailure(runId, stepId, errorMsg, 0, undefined, { markRunFailed: false });
1133
+ await checkpointStepFailure(runId, stepId, errorMsg, 0, undefined, {
1134
+ markRunFailed: false,
1135
+ fence,
1136
+ });
992
1137
  return { outcome: "failed", successors: [] };
993
1138
  }
994
1139
 
@@ -1001,13 +1146,15 @@ async function runClaimedStep(
1001
1146
  }
1002
1147
  const retryPolicy = node.validation.retry || node.retry;
1003
1148
  const currentRetryCount = existingStep?.retryCount || 0;
1004
- await checkpointStepFailure(
1149
+ const { stale: lost } = await checkpointStepFailure(
1005
1150
  runId,
1006
1151
  stepId,
1007
1152
  "Validation failed, retrying",
1008
1153
  currentRetryCount,
1009
1154
  retryPolicy,
1155
+ { fence },
1010
1156
  );
1157
+ if (lost) return stale;
1011
1158
  return { outcome: "completed", successors: [] }; // Retry handled by poller
1012
1159
  }
1013
1160
  }
@@ -1020,7 +1167,7 @@ async function runClaimedStep(
1020
1167
  result.nextPort = resolveValidationPort(node.next, result.nextPort, validationResult?.passed);
1021
1168
 
1022
1169
  // 9. Checkpoint success
1023
- await checkpointStep(runId, stepId, node.id, result, ctx);
1170
+ if (!(await checkpointStep(runId, stepId, node.id, result, ctx, { fence }))) return stale;
1024
1171
 
1025
1172
  // 10. Determine successors based on nextPort
1026
1173
  // If executor returned a specific port, use it. Otherwise, get all successors