@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
@@ -51,6 +51,12 @@ export interface ExecutorResult<TOutput = unknown> {
51
51
  output?: TOutput;
52
52
  nextPort?: string;
53
53
  error?: string;
54
+ /**
55
+ * False on a failure no retry can change (a child run that was cancelled,
56
+ * for example). The engine then skips the node's retry policy. Unset means
57
+ * retryable.
58
+ */
59
+ retryable?: boolean;
54
60
  }
55
61
 
56
62
  export interface AsyncExecutorResult<TOutput = unknown> extends ExecutorResult<TOutput> {
@@ -1,4 +1,8 @@
1
1
  import { z } from "zod";
2
+ import {
3
+ ConfigQuestionSettingsSchema,
4
+ normalizeApprovalQuestions,
5
+ } from "../../be/approval-config-question";
2
6
  import type { ExecutorMeta } from "../../types";
3
7
  import { scrubSecrets } from "../../utils/secret-scrubber";
4
8
  import { dispatchApprovalNotifications } from "../approval-notifications";
@@ -56,6 +60,15 @@ const QuestionSchema = z.discriminatedUnion("type", [
56
60
  description: z.string().optional(),
57
61
  defaultValue: z.boolean().optional(),
58
62
  }),
63
+ z.object({
64
+ id: z.string(),
65
+ type: z.literal("config"),
66
+ // Defaults to the config key during normalization in `run`.
67
+ label: z.string().optional(),
68
+ required: z.boolean().default(true),
69
+ description: z.string().optional(),
70
+ config: ConfigQuestionSettingsSchema,
71
+ }),
59
72
  ]);
60
73
 
61
74
  export const ApproverConfigSchema = z.object({
@@ -207,7 +220,18 @@ export class HumanInTheLoopExecutor extends BaseExecutor<
207
220
  override async run(input: ExecutorInput): Promise<ExecutorResult<HITLOutput>> {
208
221
  const resolved = resolveHitlQuestions(input.config?.questions);
209
222
  if (!resolved.ok) return { status: "failed", error: resolved.error };
210
- return super.run({ ...input, config: { ...input.config, questions: resolved.questions } });
223
+ // Config questions: the same create-time rules as the tool and REST paths.
224
+ const approvers = input.config?.approvers as { policy?: unknown } | undefined;
225
+ const normalized = await normalizeApprovalQuestions(resolved.questions, {
226
+ approvers: { policy: approvers?.policy },
227
+ });
228
+ if (!normalized.ok) {
229
+ return {
230
+ status: "failed",
231
+ error: `human-in-the-loop questions are invalid: ${normalized.error}`,
232
+ };
233
+ }
234
+ return super.run({ ...input, config: { ...input.config, questions: normalized.questions } });
211
235
  }
212
236
 
213
237
  protected async execute(
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { getChildWorkflowRunId, getWorkflowRunLineage } from "../../be/db/workflow-runs";
3
3
  import type { ExecutorMeta, WorkflowRun } from "../../types";
4
- import { startWorkflowExecution } from "../engine";
4
+ import { ParentStepNotCurrentError, startWorkflowExecution } from "../engine";
5
5
  import type { ExecutorDependencies, ExecutorResult } from "./base";
6
6
  import { BaseExecutor } from "./base";
7
7
  import type { ExecutorRegistry } from "./registry";
@@ -35,15 +35,21 @@ const NON_NODE_CONTEXT_KEYS = new Set(["trigger", "input", "swarm", "workflow"])
35
35
  /**
36
36
  * The parent step's outcome for a child run: its result once the child
37
37
  * completed, an error once it failed, was cancelled or was skipped, and null
38
- * while it is still running or waiting.
38
+ * while it is still running or waiting. Only a failed child is retryable: a
39
+ * cancel or a skip is a decision, and a node retry must not override it. The
40
+ * synchronous (executor) and the parked (`resumeFromChildRun`) paths both
41
+ * apply this, so the same child end gets the same policy whenever it lands.
39
42
  */
40
43
  export function childRunOutcome(
41
44
  child: WorkflowRun,
42
- ): { output: SubWorkflowOutput } | { error: string } | null {
45
+ ): { output: SubWorkflowOutput } | { error: string; retryable: boolean } | null {
43
46
  if (child.status === "running" || child.status === "waiting") return null;
44
47
  if (child.status !== "completed") {
45
48
  const reason = child.error ? `: ${child.error}` : "";
46
- return { error: `Child workflow run ${child.id} ${child.status}${reason}` };
49
+ return {
50
+ error: `Child workflow run ${child.id} ${child.status}${reason}`,
51
+ retryable: child.status === "failed",
52
+ };
47
53
  }
48
54
  const outputs: Record<string, unknown> = {};
49
55
  for (const [key, value] of Object.entries(child.context ?? {})) {
@@ -56,8 +62,9 @@ export function childRunOutcome(
56
62
 
57
63
  /**
58
64
  * Runs another workflow as a child run and waits for it. The child run records
59
- * this step as its parent, so a re-executed step reconnects to it instead of
60
- * starting a second child. The resume path is `resumeFromChildRun`.
65
+ * this step and its attempt (`retryCount`) as its parent, so a re-executed or
66
+ * recovered step reconnects to it instead of starting a second child, while a
67
+ * retry starts a new one. The resume path is `resumeFromChildRun`.
61
68
  */
62
69
  export class SubWorkflowExecutor extends BaseExecutor<
63
70
  typeof SubWorkflowConfigSchema,
@@ -82,9 +89,12 @@ export class SubWorkflowExecutor extends BaseExecutor<
82
89
  ): Promise<ExecutorResult<SubWorkflowOutput>> {
83
90
  const { db } = this.deps;
84
91
 
85
- const existingId = await getChildWorkflowRunId(meta.stepId);
92
+ // The retry poller re-executes a failed step under the same id with a
93
+ // higher retryCount; that attempt must not settle on the previous child.
94
+ const attempt = (await db.getWorkflowRunStep(meta.stepId))?.retryCount ?? 0;
95
+ const existingId = await getChildWorkflowRunId(meta.stepId, attempt);
86
96
  const existing = existingId ? await db.getWorkflowRun(existingId) : null;
87
- if (existing) return this.settle(existing);
97
+ if (existing) return this.settle(existing, config.workflowId);
88
98
 
89
99
  // Capped at the max depth, so a long chain fails closed instead of
90
100
  // hiding a cycle past the end of the scan.
@@ -109,17 +119,34 @@ export class SubWorkflowExecutor extends BaseExecutor<
109
119
  }
110
120
 
111
121
  // Returns once the child run exists; the child walks on its own. A child
112
- // that already ended (cooldown, readiness, input failure) settles here.
113
- const childRunId = await startWorkflowExecution(workflow, config.inputs ?? {}, this.registry, {
114
- requestedByUserId: meta.requestedByUserId,
115
- parentStepId: meta.stepId,
116
- });
122
+ // that already ended (cooldown, readiness, input failure) settles here,
123
+ // and a concurrent execution of this attempt gets the child it created.
124
+ let childRunId: string;
125
+ try {
126
+ childRunId = await startWorkflowExecution(workflow, config.inputs ?? {}, this.registry, {
127
+ requestedByUserId: meta.requestedByUserId,
128
+ parentStepId: meta.stepId,
129
+ parentStepAttempt: attempt,
130
+ });
131
+ } catch (err) {
132
+ if (err instanceof ParentStepNotCurrentError) return { status: "failed", error: err.message };
133
+ throw err;
134
+ }
117
135
  const child = await db.getWorkflowRun(childRunId);
118
136
  if (!child) return { status: "failed", error: `Child workflow run ${childRunId} not found` };
119
- return this.settle(child);
137
+ return this.settle(child, config.workflowId);
120
138
  }
121
139
 
122
- private settle(child: WorkflowRun): ExecutorResult<SubWorkflowOutput> {
140
+ private settle(child: WorkflowRun, workflowId: string): ExecutorResult<SubWorkflowOutput> {
141
+ // The attempt already started a child of another workflow (the config
142
+ // changed under a recovered step). That is a conflict, not a reason to
143
+ // start a replacement next to it.
144
+ if (child.workflowId !== workflowId) {
145
+ return {
146
+ status: "failed",
147
+ error: `Child workflow run ${child.id} runs workflow ${child.workflowId}, not ${workflowId}`,
148
+ };
149
+ }
123
150
  const outcome = childRunOutcome(child);
124
151
  if (!outcome) {
125
152
  return {
@@ -129,7 +156,9 @@ export class SubWorkflowExecutor extends BaseExecutor<
129
156
  correlationId: child.id,
130
157
  } as ExecutorResult<SubWorkflowOutput>;
131
158
  }
132
- if ("error" in outcome) return { status: "failed", error: outcome.error };
159
+ if ("error" in outcome) {
160
+ return { status: "failed", error: outcome.error, retryable: outcome.retryable };
161
+ }
133
162
  return { status: "success", output: outcome.output, nextPort: "success" };
134
163
  }
135
164
  }
@@ -12,7 +12,7 @@ import {
12
12
  resolveApprovalRequest,
13
13
  updateWorkflowRun,
14
14
  } from "../be/db";
15
- import { getSettledChildRunParentSteps } from "../be/db/workflow-runs";
15
+ import { getRunningSubWorkflowSteps, getSettledChildRunParentSteps } from "../be/db/workflow-runs";
16
16
  import type { WorkflowRunStep } from "../types";
17
17
  import { shapeApprovalResolution } from "./approval-resolution";
18
18
  import { loadCompletedStepRouting } from "./completed-step-routing";
@@ -20,6 +20,7 @@ import { FAILED_TASK_OUTPUT_PREFIX } from "./constants";
20
20
  import {
21
21
  failRunOnUnreadableReplay,
22
22
  findReadyNodes,
23
+ isStepExecuting,
23
24
  isWorkflowRunActive,
24
25
  walkGraph,
25
26
  } from "./engine";
@@ -76,6 +77,9 @@ export async function recoverIncompleteRuns(registry: ExecutorRegistry): Promise
76
77
  // --- Case 6: foreach fan-outs with queued or orphaned items ---
77
78
  recovered += await recoverForeachFanouts(registry);
78
79
 
80
+ // --- Case 7: sub-workflow steps a crash left running ---
81
+ recovered += await recoverOrphanedSubWorkflowSteps(registry);
82
+
79
83
  if (recovered > 0) {
80
84
  console.log(`[workflows] Recovered ${recovered} incomplete run(s)`);
81
85
  }
@@ -98,6 +102,13 @@ async function recoverRunningRuns(registry: ExecutorRegistry): Promise<number> {
98
102
  if (isWorkflowRunActive(runId)) continue;
99
103
  const run = await getWorkflowRun(runId);
100
104
  if (!run || run.status !== "running") continue;
105
+ // A child run commits with its initial context. One without it was
106
+ // created before that held and lost its trigger and inputs in a crash;
107
+ // walking it from an empty context would run its nodes on blanks.
108
+ if (run.parentStepId && run.context === undefined) {
109
+ await failRunningRun(runId, "Child workflow run lost its initial context in a restart");
110
+ continue;
111
+ }
101
112
 
102
113
  const workflow = await getWorkflow(run.workflowId);
103
114
  if (!workflow) continue;
@@ -146,6 +157,57 @@ async function recoverRunningRuns(registry: ExecutorRegistry): Promise<number> {
146
157
  return recovered;
147
158
  }
148
159
 
160
+ /** Fail a run that is still `running`; a cancel or finish that committed first wins. */
161
+ async function failRunningRun(runId: string, error: string): Promise<void> {
162
+ await getDbClient().transaction(async () => {
163
+ const run = await getWorkflowRun(runId);
164
+ if (run?.status !== "running") return;
165
+ await updateWorkflowRun(runId, {
166
+ status: "failed",
167
+ error,
168
+ finishedAt: new Date().toISOString(),
169
+ });
170
+ });
171
+ }
172
+
173
+ /**
174
+ * Re-enter `sub-workflow` steps a crash left `running`. The running-run case
175
+ * misses two shapes: a run another branch already parked in `waiting`, and a
176
+ * loop iteration of a node that completed before. Walking the node lets
177
+ * executeStep re-claim the same step row, so the executor reconnects to the
178
+ * child that attempt already started. Foreach items are left to the fan-out
179
+ * case: their synthetic node ids are not in the definition.
180
+ */
181
+ async function recoverOrphanedSubWorkflowSteps(registry: ExecutorRegistry): Promise<number> {
182
+ let recovered = 0;
183
+ for (const row of await getRunningSubWorkflowSteps()) {
184
+ try {
185
+ if (isWorkflowRunActive(row.runId) || isStepExecuting(row.stepId)) continue;
186
+ const run = await getWorkflowRun(row.runId);
187
+ if (!run || (run.status !== "running" && run.status !== "waiting")) continue;
188
+ const workflow = await getWorkflow(run.workflowId);
189
+ const node = workflow?.definition.nodes.find((n) => n.id === row.nodeId);
190
+ if (!workflow || !node) continue;
191
+ // DB reads above yield; a resume may have acquired the run meanwhile.
192
+ if (isWorkflowRunActive(row.runId)) continue;
193
+ await walkGraph(
194
+ workflow.definition,
195
+ row.runId,
196
+ (run.context ?? {}) as Record<string, unknown>,
197
+ [node],
198
+ registry,
199
+ workflow.id,
200
+ getSecretInputKeys(workflow.input),
201
+ );
202
+ recovered++;
203
+ } catch (err) {
204
+ if (await failRunOnUnreadableReplay(row.runId, err)) continue;
205
+ console.error(`[workflows] Failed to recover sub-workflow step ${row.stepId}:`, err);
206
+ }
207
+ }
208
+ return recovered;
209
+ }
210
+
149
211
  /** Nodes with a failed step the retry poller will pick up (`nextRetryAt` set). */
150
212
  function retryPendingNodeIds(steps: WorkflowRunStep[]): Set<string> {
151
213
  const nodeIds = new Set<string>();
@@ -18,12 +18,14 @@ import {
18
18
  updateWorkflowRunStep,
19
19
  } from "../be/db";
20
20
  import { createForeachAttemptSteps, getChildWorkflowRunId } from "../be/db/workflow-runs";
21
- import type {
22
- WaitStateRow,
23
- WorkflowDefinition,
24
- WorkflowNode,
25
- WorkflowRun,
26
- WorkflowRunStep,
21
+ import {
22
+ type RetryPolicy,
23
+ RetryPolicySchema,
24
+ type WaitStateRow,
25
+ type WorkflowDefinition,
26
+ type WorkflowNode,
27
+ type WorkflowRun,
28
+ type WorkflowRunStep,
27
29
  } from "../types";
28
30
  import { scrubSecrets } from "../utils/secret-scrubber";
29
31
  import {
@@ -31,6 +33,7 @@ import {
31
33
  postApprovalCancellationUpdates,
32
34
  } from "./approval-notifications";
33
35
  import { shapeApprovalResolution } from "./approval-resolution";
36
+ import { checkpointStepFailure } from "./checkpoint";
34
37
  import { loadCompletedStepRouting } from "./completed-step-routing";
35
38
  import { FAILED_TASK_OUTPUT_PREFIX } from "./constants";
36
39
  import { getNextTargets } from "./definition";
@@ -963,10 +966,11 @@ async function resumeClaimedWait(
963
966
  }
964
967
 
965
968
  /**
966
- * Resume a waiting `sub-workflow` step once its child run is terminal: a
967
- * completed child completes the step, any other end (or a deleted child) fails
968
- * the step and its run. Returns true when this call claimed the step. Safe to call repeatedly:
969
- * the live event and the recovery sweep both route here.
969
+ * Resume a waiting `sub-workflow` step once its current attempt's child run is
970
+ * terminal: a completed child completes the step, any other end (or a deleted
971
+ * child) fails the step and its run. Returns true when this call claimed the
972
+ * step. Safe to call repeatedly: the live event and the recovery sweep both
973
+ * route here, and only the call that claims the step walks its successors.
970
974
  */
971
975
  export async function resumeFromChildRun(
972
976
  parentStepId: string,
@@ -974,13 +978,6 @@ export async function resumeFromChildRun(
974
978
  ): Promise<boolean> {
975
979
  const step = await getWorkflowRunStep(parentStepId);
976
980
  if (!step || step.status !== "waiting" || step.nodeType !== "sub-workflow") return false;
977
- const childId = await getChildWorkflowRunId(parentStepId);
978
- const child = childId ? await getWorkflowRun(childId) : null;
979
- // The child row exists before the step parks, so a missing one was deleted.
980
- const outcome = child
981
- ? childRunOutcome(child)
982
- : { error: `Child workflow run of step ${parentStepId} was deleted` };
983
- if (!outcome) return false;
984
981
 
985
982
  let claimed = false;
986
983
  await failClosedOnUnreadableReplay(step.runId, step.id, async () => {
@@ -990,10 +987,32 @@ export async function resumeFromChildRun(
990
987
  if (!workflow) return;
991
988
 
992
989
  const ctx = (run.context ?? {}) as Record<string, unknown>;
993
- // A foreach item's failure settles the item: under `onNodeFailure:
994
- // "continue"` it can close the join, so it routes like a success.
995
- const routing =
996
- "error" in outcome
990
+ // The step's attempt, that attempt's child and the claim share one
991
+ // transaction: a retry that re-parks the step under a newer attempt in
992
+ // between must not be settled by the previous attempt's child.
993
+ const routing = await getDbClient().transaction(async () => {
994
+ const current = await getWorkflowRunStep(step.id);
995
+ if (!current || current.status !== "waiting") return null;
996
+ const childId = await getChildWorkflowRunId(step.id, current.retryCount);
997
+ const child = childId ? await getWorkflowRun(childId) : null;
998
+ // The child row exists before the step parks, so a missing one was deleted.
999
+ const outcome = child
1000
+ ? childRunOutcome(child)
1001
+ : { error: `Child workflow run of step ${parentStepId} was deleted`, retryable: false };
1002
+ if (!outcome) return null;
1003
+ // A retryable end (a failed child) is this attempt failing: the node's
1004
+ // retry policy applies exactly as on the executor path. The poller
1005
+ // re-executes the step under the next attempt, which starts a new child.
1006
+ if ("error" in outcome && outcome.retryable) {
1007
+ const policy = childStepRetryPolicy(workflow.definition, step.nodeId);
1008
+ if (policy && current.retryCount < policy.maxRetries) {
1009
+ await checkpointStepFailure(run.id, step.id, outcome.error, current.retryCount, policy);
1010
+ return CHILD_RETRY_SCHEDULED;
1011
+ }
1012
+ }
1013
+ // A foreach item's failure settles the item: under `onNodeFailure:
1014
+ // "continue"` it can close the join, so it routes like a success.
1015
+ return "error" in outcome
997
1016
  ? await failStepAndRunIfWaiting(
998
1017
  workflow.definition,
999
1018
  step.id,
@@ -1012,6 +1031,12 @@ export async function resumeFromChildRun(
1012
1031
  ctx,
1013
1032
  registry,
1014
1033
  );
1034
+ });
1035
+ if (!routing) return;
1036
+ if (routing === CHILD_RETRY_SCHEDULED) {
1037
+ claimed = true;
1038
+ return;
1039
+ }
1015
1040
  if (!routing.claimed || routing.runFailed) {
1016
1041
  claimed = routing.claimed;
1017
1042
  return;
@@ -1036,6 +1061,20 @@ export async function resumeFromChildRun(
1036
1061
  return claimed;
1037
1062
  }
1038
1063
 
1064
+ const CHILD_RETRY_SCHEDULED = {
1065
+ claimed: true,
1066
+ runFailed: false,
1067
+ successors: [] as WorkflowNode[],
1068
+ } as const;
1069
+
1070
+ /** The `node.retry` of a top-level `sub-workflow` node; foreach items retry through their parent. */
1071
+ function childStepRetryPolicy(def: WorkflowDefinition, nodeId: string): RetryPolicy | null {
1072
+ const node = def.nodes.find((candidate) => candidate.id === nodeId);
1073
+ if (!node?.retry) return null;
1074
+ const parsed = RetryPolicySchema.safeParse(node.retry);
1075
+ return parsed.success ? parsed.data : null;
1076
+ }
1077
+
1039
1078
  // ─── 64KB firedPayload cap ──────────────────────────────────────────────────
1040
1079
 
1041
1080
  const FIRED_PAYLOAD_BYTE_CAP = 64 * 1024; // 64KB
@@ -61,6 +61,9 @@ export function startRetryPoller(registry: ExecutorRegistry, intervalMs = 5000):
61
61
  // the run were read outside it: a cancel committing in between must
62
62
  // win, or the step re-executes inside a cancelled run.
63
63
  if (!(await claimRetry(run.id, step.id))) continue;
64
+ // Every result write below is fenced on this claim, so a cancel or
65
+ // another settle that commits while the executor runs wins.
66
+ const fence = { retryCount: step.retryCount };
64
67
 
65
68
  // Rebuild `run.id`, upstream outputs and the node's declared-inputs
66
69
  // context the way the original execution saw them. Without the alias
@@ -78,6 +81,8 @@ export function startRetryPoller(registry: ExecutorRegistry, intervalMs = 5000):
78
81
  step.id,
79
82
  scriptBodyInterpolationError(node.id, scriptBodyUnresolved),
80
83
  step.retryCount,
84
+ undefined,
85
+ { fence },
81
86
  );
82
87
  continue;
83
88
  }
@@ -104,14 +109,17 @@ export function startRetryPoller(registry: ExecutorRegistry, intervalMs = 5000):
104
109
  if (result.status === "failed") {
105
110
  // Re-failed — use the EXISTING retryCount from the step.
106
111
  // checkpointStepFailure handles marking run as failed if no retries left,
107
- // or setting nextRetryAt for the next poll cycle.
108
- const retryPolicy = node.retry || executor.retryPolicy;
112
+ // or setting nextRetryAt for the next poll cycle. A non-retryable
113
+ // failure skips the policy, as in executeStep.
114
+ const retryPolicy =
115
+ result.retryable === false ? undefined : node.retry || executor.retryPolicy;
109
116
  await checkpointStepFailure(
110
117
  run.id,
111
118
  step.id,
112
119
  result.error || "Retry failed",
113
120
  step.retryCount,
114
121
  retryPolicy,
122
+ { fence, output: result.output },
115
123
  );
116
124
  } else if (
117
125
  "async" in result &&
@@ -150,6 +158,12 @@ export function startRetryPoller(registry: ExecutorRegistry, intervalMs = 5000):
150
158
  // find the parent already advanced.
151
159
  const waiting = await checkpointStepWaiting(run.id, step.id, ctx);
152
160
  if (!waiting) continue;
161
+ // As in executeStep: a child run that finished before the step
162
+ // parked had its event dropped.
163
+ if ((result as AsyncExecutorResult).waitFor === "workflow.child.finished") {
164
+ const { resumeFromChildRun } = await import("./resume");
165
+ await resumeFromChildRun(step.id, registry);
166
+ }
153
167
  } else {
154
168
  // Success! Re-run validation if configured before checkpointing.
155
169
  let validationPassed: boolean | undefined;
@@ -168,6 +182,8 @@ export function startRetryPoller(registry: ExecutorRegistry, intervalMs = 5000):
168
182
  step.id,
169
183
  "Validation failed (mustPass)",
170
184
  step.retryCount,
185
+ undefined,
186
+ { fence },
171
187
  );
172
188
  continue;
173
189
  }
@@ -186,6 +202,7 @@ export function startRetryPoller(registry: ExecutorRegistry, intervalMs = 5000):
186
202
  "Validation failed, retrying",
187
203
  step.retryCount,
188
204
  retryPolicy,
205
+ { fence },
189
206
  );
190
207
  continue;
191
208
  }
@@ -197,7 +214,9 @@ export function startRetryPoller(registry: ExecutorRegistry, intervalMs = 5000):
197
214
  result.nextPort = resolveValidationPort(node.next, result.nextPort, validationPassed);
198
215
 
199
216
  // Validation passed (or no validation) — checkpoint and continue
200
- await checkpointStep(run.id, step.id, step.nodeId, result, ctx);
217
+ if (!(await checkpointStep(run.id, step.id, step.nodeId, result, ctx, { fence }))) {
218
+ continue;
219
+ }
201
220
 
202
221
  const port = result.nextPort || "default";
203
222
  const successors = getSuccessors(workflow.definition, step.nodeId, port);
@@ -219,7 +238,9 @@ export function startRetryPoller(registry: ExecutorRegistry, intervalMs = 5000):
219
238
  if (await failRunOnUnreadableReplay(run.id, err, step.id)) continue;
220
239
  const errorMsg = err instanceof Error ? err.message : String(err);
221
240
  const retryPolicy = node.retry || executor.retryPolicy;
222
- await checkpointStepFailure(run.id, step.id, errorMsg, step.retryCount, retryPolicy);
241
+ await checkpointStepFailure(run.id, step.id, errorMsg, step.retryCount, retryPolicy, {
242
+ fence,
243
+ });
223
244
  }
224
245
  } catch (err) {
225
246
  if (await failRunOnUnreadableReplay(step.runId, err, step.id)) continue;
@@ -1,14 +1,14 @@
1
1
  # comms-adherence-gate
2
2
 
3
- Agents finish Slack tasks with a final message a human reads. This extension judges that message against the requester's stated comms preferences and their latest request with Jev, [TypeSafe](https://typesafe.ai)'s decision model. In `enforce` it blocks a non-adherent completion once and tells the agent what to fix.
3
+ Agents finish Slack tasks with a final message a human reads, and post to Slack threads directly. This extension judges that text with Jev, [TypeSafe](https://typesafe.ai)'s decision model: against the requester's stated comms preferences and their latest request, and against a plain-language rubric that applies to every message. In `enforce` it blocks a failing call once and tells the agent what to fix. A `plain` failure points the agent at the `comms` skill.
4
4
 
5
5
  Install config:
6
6
 
7
7
  ```json
8
- { "mode": "shadow" }
8
+ { "mode": "shadow", "plainMode": "shadow" }
9
9
  ```
10
10
 
11
- Start in `shadow`, read the verdicts in KV for a few days, then switch to `enforce`.
11
+ Start in `shadow`, read the verdicts in KV for a few days, then switch to `enforce`. `mode` and `plainMode` move independently.
12
12
 
13
13
  ## Modes
14
14
 
@@ -16,14 +16,22 @@ Start in `shadow`, read the verdicts in KV for a few days, then switch to `enfor
16
16
  |---|---|
17
17
  | `off` | Never evaluates. |
18
18
  | `shadow` (default) | Asks Jev and records the verdict. Never blocks. |
19
- | `enforce` | Asks Jev, records the verdict, and blocks the `store-progress` call when a dimension fails, at most `maxBlocksPerTask` times per task. The agent gets the failed dimensions, their scores and the stated preference, and calls again. |
19
+ | `enforce` | Asks Jev, records the verdict, and blocks the call when a dimension fails, at most `maxBlocksPerTask` times per task. The agent gets the failed dimensions, their scores and the stated preference, and calls again. |
20
+
21
+ `mode` governs the preference dimensions (`verbosity`, `tone`, `language`). `plainMode` governs `plain`. A dimension blocks only when its own mode is `enforce`. Both share one block budget per task, across every gated tool.
20
22
 
21
23
  ## What it gates
22
24
 
23
- Hook: `pre.tool.call` on `store-progress` with `status: "completed"` and a non-empty `output`. Calls made by extensions are skipped. The task must:
25
+ Hook: `pre.tool.call` on:
26
+
27
+ - `store-progress` with `status: "completed"` and a non-empty `output`, on a task that came from Slack (`source = "slack"` or a `slackThreadTs`). Other completions pass without a Jev call.
28
+ - `slack-reply`, `slack-post`, `slack-start-thread` and `slack-update`, judged on `message`. The task is `slack-reply`'s `taskId`, else the caller's task context. A post with no task context passes.
29
+
30
+ Only a call the tool itself accepts is judged, so no other task's text reaches Jev. For `store-progress` the caller must own the task, the task must be unassigned, or the caller must be a lead. For the Slack tools the caller must be the task's assignee or its creator, the rule `slack-reply` enforces. Calls made by extensions are skipped. Script SDK calls never reach `pre.tool.call` (the tool registrar dispatches it only for MCP calls); if one ever does, the gate records it and never blocks.
24
31
 
25
- 1. Come from Slack (`source = "slack"` or a `slackThreadTs`). Other tasks pass without a Jev call.
26
- 2. Have a requester with stated preferences in `users.metadata.comms`. A requester with none passes. Set them with the `manage-user` tool.
32
+ A requester with no stated preferences in `users.metadata.comms` still gets the `plain` question. Set preferences with the `manage-user` tool.
33
+
34
+ In a thread with several people, the requester is the person whose message created the task. A message that @-mentions someone (`<@U123>`) is judged against the first mentioned person with stored preferences, resolved through their Slack identity in `user_external_ids`; otherwise against the requester. The lookup is one SQL read, only when the text mentions someone and `mode` is not `off`. The verdict records who was judged in `judgedFor` (`mention` or `requester`) and `audience`.
27
35
 
28
36
  ## Decision
29
37
 
@@ -31,7 +39,21 @@ The gate reads the task row and asks one `noul` question per stated preference i
31
39
 
32
40
  Jev sees the preferences, the requester's latest message (the text after the last `</thread_context>`, from the last 1,400 characters of the task), the first 400 characters of the task, and the output. Its judging rules say an explicit request outranks the stored default. A requested long answer, story or document is not a verbosity failure.
33
41
 
34
- A dimension fails when its score is below `threshold`. Any failed dimension fails the output.
42
+ It also asks `plain` on every message unless `plainMode` is `off` or the text is a JSON document (structured output for an `outputSchema`). The rubric comes from the `comms` skill: everyday words, one idea per sentence, every number with a unit and a direction word, and no unexplained shorthand (n, median, p90, cohort, baseline, delta, `±X at 95%`). Code identifiers, task ids, PR numbers, links and versions are fine. Jev gets contrasting plain and dense examples in `plain_language_rules`; a prose-only rubric scored plain rewrites as low as the dense originals.
43
+
44
+ All questions go in one Jev request. A preference dimension fails below `threshold`; `plain` fails below `plainThreshold`. Any failed dimension fails the message.
45
+
46
+ ### Calibration of `plain`
47
+
48
+ Jev scores dense status text near 0 and plain text near 0.8 to 0.9, with a wide middle of compact but readable engineering notes. Measured on 400 Slack-facing completions over 7 days, at `threshold` 0.75 for preferences:
49
+
50
+ | `plainThreshold` | Newly blocked (of messages that pass today) |
51
+ |---|---|
52
+ | 0.75 | 81% to 83% |
53
+ | 0.3 | 9% to 13% |
54
+ | 0.25 (default) | 5% to 7% |
55
+
56
+ At 0.25 the dense prompt-cache-monitor post scores about 0.15 and its plain rewrite about 0.81.
35
57
 
36
58
  ## Jev transport
37
59
 
@@ -51,14 +73,16 @@ Every error lets the completion through: a missing key, an HTTP error, a network
51
73
 
52
74
  ## Self-test
53
75
 
54
- Call `store-progress` with a non-completed status and `progress` starting with `comms-gate-selftest`. The gate judges a known-bad and a known-good message against a terse preference and writes `selftest:last`: both scores, the threshold, the provider, and `controlsOk` (bad below threshold and good at or above it). Real traffic is untouched.
76
+ Call `store-progress` with a non-completed status and `progress` starting with `comms-gate-selftest`. The gate judges a known-bad and a known-good message against a terse preference, and a dense and a plain message on `plain`, then writes `selftest:last`: every score, both thresholds, the provider, `controlsOk` (verbosity: bad below `threshold`, good at or above it) and `plainControlsOk` (the same against `plainThreshold`). Real traffic is untouched.
55
77
 
56
78
  ## Config
57
79
 
58
80
  | Key | Default | Notes |
59
81
  |---|---|---|
60
82
  | `mode` | `shadow` | `off`, `shadow`, or `enforce` |
61
- | `threshold` | `0.5` | A dimension scoring below this fails |
83
+ | `threshold` | `0.5` | A preference dimension scoring below this fails |
84
+ | `plainMode` | `shadow` | `off`, `shadow`, or `enforce` for the `plain` dimension |
85
+ | `plainThreshold` | `0.25` | `plain` scoring below this fails. See [Calibration](#calibration-of-plain) |
62
86
  | `timeoutMs` | `4000` | Jev call timeout, capped at 4000 (the dispatcher cancels a handler at 5 s) |
63
87
  | `maxBlocksPerTask` | `1` | Blocks per task in `enforce`; after that the gate records and lets it through |
64
88
  | `provider` | `auto` | `auto`, `typesafe`, or `openrouter`. See [Jev transport](#jev-transport) |
@@ -76,13 +100,14 @@ No key is written to config, KV, logs, or a block reason.
76
100
 
77
101
  ## KV namespace `ext:comms-gate`
78
102
 
79
- - `last`: the latest verdict, with `scores`, `failed`, `provider`, `latencyMs`, the preferences, a 400-character `askPreview` and a 700-character `outputPreview`.
103
+ - `last`: the latest verdict, with `tool`, `scores`, `failed`, `mode`, `plainMode`, `provider`, `latencyMs`, the preferences, a 400-character `askPreview` and a 700-character `outputPreview`.
80
104
  - `fail:<epochMs>:<taskId>`: one row per failed verdict, with `action` (`blocked` or `recorded`).
81
105
  - `blocks:<taskId>`: blocks issued for that task.
82
106
  - `error:last`, `selftest:last`.
83
- - Counters: `total`, `pass`, `fail`, `blocked`, `escaped` (enforce, block budget spent), `shadowed`, `error`, and `dim:<dimension>`.
107
+ - Counters: `total`, `pass`, `fail`, `blocked`, `escaped` (a blocking dimension failed, block budget spent), `shadowed` (only non-blocking dimensions failed), `error`, and `dim:<dimension>`.
84
108
 
85
109
  ## Known limits
86
110
 
87
111
  - Only the last 1,400 characters of the task are searched for the latest request. A very long final message is cut to its last 900 characters.
88
- - The block budget is per task, so a retried completion after a block goes through once the budget is spent, even if it still fails.
112
+ - The block budget is per task, so a retried call after a block goes through once the budget is spent, even if it still fails.
113
+ - Each gated Slack post waits for one Jev call (about 0.2 s measured, capped by `timeoutMs`).