@desplega.ai/agent-swarm 1.129.0 → 1.131.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (226) hide show
  1. package/README.md +2 -2
  2. package/dist/{actions-kfv2dawe.js → actions-w53mbns9.js} +11 -11
  3. package/dist/{anthropic-messages-3xgme8my.js → anthropic-messages-rjmd2e7n.js} +5 -5
  4. package/dist/{app-q8vjk6e8.js → app-m9gwqsqv.js} +8 -8
  5. package/dist/{artifact-hg4t1nx7.js → artifact-jysj3wrg.js} +2 -2
  6. package/dist/{assistant-g037r345.js → assistant-bp65m6f3.js} +13 -13
  7. package/dist/{azure-openai-responses-xa4sh9f6.js → azure-openai-responses-c0yjmbfd.js} +8 -5
  8. package/dist/{boot-reembed-18pzsrxa.js → boot-reembed-62p0at2k.js} +6 -6
  9. package/dist/{boot-reembed-wpks2cj8.js → boot-reembed-xmfp6wdh.js} +7 -7
  10. package/dist/{boot-scrub-logs-n7szd57r.js → boot-scrub-logs-rr8q2dms.js} +5 -5
  11. package/dist/{claude-adapter-qz4r0ajt.js → claude-adapter-2y47mrkd.js} +9 -9
  12. package/dist/{cli-mhjnbgb6.js → cli-0rcyq6d9.js} +450 -186
  13. package/dist/{cli-ttkmehy0.js → cli-2q72qz2s.js} +274 -25
  14. package/dist/{cli-gztgc3hz.js → cli-2wadx7p2.js} +2 -2
  15. package/dist/cli-3fj60etf.js +153 -0
  16. package/dist/{cli-7ecgm5v0.js → cli-3kf51j15.js} +1 -1
  17. package/dist/{cli-vgexpfsr.js → cli-57khth04.js} +6 -3
  18. package/dist/{cli-2mbe7vk0.js → cli-5dsbcjjw.js} +1 -1
  19. package/dist/{cli-etr62zy6.js → cli-5kx36evn.js} +2 -2
  20. package/dist/{cli-v2tr6amf.js → cli-5n6e5yfn.js} +30 -8
  21. package/dist/{cli-hx02s9zq.js → cli-5v4rw6yd.js} +2 -2
  22. package/dist/{cli-v37yr6mf.js → cli-5ze9rv97.js} +4 -4
  23. package/dist/{cli-ch7t5v7n.js → cli-6map995v.js} +9 -4
  24. package/dist/{cli-h0457a67.js → cli-70xqj0z3.js} +3 -3
  25. package/dist/{cli-kk4f9r17.js → cli-72q8m8gk.js} +8 -6
  26. package/dist/{cli-7padk69a.js → cli-78xwvkhj.js} +2 -2
  27. package/dist/{cli-dxtxacy7.js → cli-9dr5qzc0.js} +45 -8
  28. package/dist/{cli-17zxdgsq.js → cli-a40rnf71.js} +6 -6
  29. package/dist/{cli-t53vnnxz.js → cli-a4jzdpa3.js} +2514 -1055
  30. package/dist/{cli-w10fkrf1.js → cli-akdvwc9v.js} +354 -115
  31. package/dist/{cli-1qkk5er0.js → cli-bmze986d.js} +1 -1
  32. package/dist/{cli-ga12dc1d.js → cli-bqw0mt1n.js} +3 -3
  33. package/dist/{cli-0sf92efp.js → cli-cdeg08wx.js} +1 -1
  34. package/dist/{cli-a8ynhkyj.js → cli-cvgjwnh1.js} +17 -15
  35. package/dist/{cli-9fxg3v5m.js → cli-d2tfkcga.js} +4 -4
  36. package/dist/{cli-nhcd7x43.js → cli-d3x3x1c9.js} +2 -2
  37. package/dist/{cli-6nh2qxvh.js → cli-darcwax3.js} +1 -1
  38. package/dist/{cli-yjbgmysx.js → cli-e7ckcg4v.js} +12 -12
  39. package/dist/{cli-4n2gcjpd.js → cli-efxfnq6q.js} +4 -3
  40. package/dist/{cli-z9xqqhfk.js → cli-er3a4bb0.js} +1 -1
  41. package/dist/{cli-xg2eezyv.js → cli-f1am9szv.js} +1 -1
  42. package/dist/{cli-xy4cwsm9.js → cli-fywms69x.js} +1 -1
  43. package/dist/{cli-33jj76y8.js → cli-g6b8x1h8.js} +1 -1
  44. package/dist/{cli-epsvdckx.js → cli-gm9cbhbq.js} +20 -10
  45. package/dist/{cli-rkndnn90.js → cli-k9qkf1hw.js} +6 -4
  46. package/dist/{cli-zejvt395.js → cli-kh3ba2zf.js} +1 -1
  47. package/dist/{cli-gb25p1h2.js → cli-nm5gtp1x.js} +2 -2
  48. package/dist/{cli-akxnpx17.js → cli-pm6xbh84.js} +1829 -1629
  49. package/dist/{cli-2k0ztbfp.js → cli-psh3c4va.js} +1 -1
  50. package/dist/{cli-39vgrsep.js → cli-qv61hp37.js} +2 -2
  51. package/dist/{cli-hv2fndhm.js → cli-r6kqqbj0.js} +6 -4
  52. package/dist/{cli-1y1cm570.js → cli-rzbpmxdr.js} +1 -1
  53. package/dist/{cli-mmrk00vp.js → cli-t1xze5qh.js} +1 -1
  54. package/dist/{cli-g252a9ky.js → cli-t55yw3a1.js} +2 -2
  55. package/dist/{cli-7bz8dzqa.js → cli-t76vw5db.js} +1184 -1522
  56. package/dist/{cli-4n4mdv21.js → cli-xpxde7rm.js} +7 -3
  57. package/dist/{cli-fpaa5k2q.js → cli-yw8vghx2.js} +54 -18
  58. package/dist/cli-zsspvvm2.js +490 -0
  59. package/dist/cli.js +27 -25
  60. package/dist/{codex-adapter-7v6t7ff6.js → codex-adapter-d24q75wk.js} +7 -7
  61. package/dist/{codex-hook-amndawyb.js → codex-hook-hq54t9ft.js} +2 -2
  62. package/dist/{codex-session-runner-e7j4khe2.js → codex-session-runner-g50vcsex.js} +7 -7
  63. package/dist/{commands-ex0k5tbf.js → commands-c3nrc5hg.js} +5 -5
  64. package/dist/{db-df7jatfc.js → db-nt7fg3tw.js} +5 -5
  65. package/dist/{google-generative-ai-y42hnw9p.js → google-generative-ai-hysrd8qx.js} +7 -5
  66. package/dist/{google-vertex-4sg66gb6.js → google-vertex-p5arrga0.js} +7 -5
  67. package/dist/{handlers-1j2cfqt2.js → handlers-fkr6swat.js} +13 -13
  68. package/dist/{hook-nx9a0mj3.js → hook-77wpvnjc.js} +7 -7
  69. package/dist/{hook-w6yaf6sy.js → hook-g019yehy.js} +8 -8
  70. package/dist/{http-4tz5h8wg.js → http-jewj0syk.js} +2993 -716
  71. package/dist/{index-0bpkzb09.js → index-070v4eyb.js} +3 -3
  72. package/dist/{index-0s67qv7h.js → index-6gm5he8h.js} +11 -11
  73. package/dist/{index-35gtv6gj.js → index-7n2xpbd4.js} +310 -40
  74. package/dist/{index-ker3gccb.js → index-9znhar3x.js} +4 -4
  75. package/dist/{index-5n2c8yay.js → index-hase6zbe.js} +2 -2
  76. package/dist/{index-kcf3tm9h.js → index-hfvggd0z.js} +12 -12
  77. package/dist/{index-7c0jqna0.js → index-hp1kdbjj.js} +13 -13
  78. package/dist/{keepalive-3j4a9168.js → keepalive-j0fjvqfd.js} +7 -7
  79. package/dist/{lead-6kpevymb.js → lead-1n8x30xe.js} +33 -33
  80. package/dist/{maintenance-m1k82ean.js → maintenance-gvev11sa.js} +7 -7
  81. package/dist/{mistral-conversations-bjkdfb6z.js → mistral-conversations-81fwav0j.js} +6 -6
  82. package/dist/{oauth-refresh-sweep-881k8e4q.js → oauth-refresh-sweep-s9ecsvh0.js} +6 -6
  83. package/dist/{onboard-c0ykffjn.js → onboard-pvm5b38w.js} +2 -2
  84. package/dist/{openai-codex-responses-xetseqvt.js → openai-codex-responses-5g27pd6f.js} +43 -23
  85. package/dist/{openai-completions-5r2g5axa.js → openai-completions-3vkmfz2a.js} +55 -11
  86. package/dist/{openai-responses-ce67hd23.js → openai-responses-dg4t7j7a.js} +8 -5
  87. package/dist/{openrouter-images-pzfp22jb.js → openrouter-images-g3gm8931.js} +1 -1
  88. package/dist/{otel-impl-sqagjerv.js → otel-impl-p6jvgd9c.js} +6 -4
  89. package/dist/{otel-3mp81ppd.js → otel-nqa12wf3.js} +2 -2
  90. package/dist/{pi-mono-adapter-k584k16g.js → pi-mono-adapter-t091s27g.js} +17117 -7517
  91. package/dist/{pricing-refresh-y82whbf9.js → pricing-refresh-k49rcyed.js} +7 -7
  92. package/dist/{rbac-roles-nvkqmzyd.js → rbac-roles-30pspvy7.js} +7 -7
  93. package/dist/{rbac-roles-4ht8gegc.js → rbac-roles-cr80g96t.js} +6 -6
  94. package/dist/{render-v2-wvqdgvvp.js → render-v2-xm457ng1.js} +8 -8
  95. package/dist/{seed-pricing-fy3rxhde.js → seed-pricing-yhpv35fr.js} +6 -6
  96. package/dist/{setup-ak5cnc6f.js → setup-nytnmwwr.js} +2 -2
  97. package/dist/{worker-d8769q8a.js → worker-msytr2cw.js} +33 -33
  98. package/openapi.json +34039 -11036
  99. package/package.json +8 -6
  100. package/src/apps/definition.ts +368 -26
  101. package/src/apps/row-store.ts +66 -10
  102. package/src/apps/schema-migrate.ts +95 -1
  103. package/src/apps/store.ts +13 -4
  104. package/src/apps/sync-run-as.ts +18 -0
  105. package/src/apps/sync.ts +934 -0
  106. package/src/apps/version.ts +16 -2
  107. package/src/assets/key.ts +8 -1
  108. package/src/be/asset-key-audit.ts +3 -1
  109. package/src/be/db-queries/mcp-oauth.ts +297 -24
  110. package/src/be/db.ts +42 -1
  111. package/src/be/migrations/129_asset_keys_apps_scripts.sql +161 -0
  112. package/src/be/scripts/db.ts +5 -2
  113. package/src/be/scripts/typecheck.ts +1 -0
  114. package/src/be/seed-scripts/catalog/app-sync-run.ts +32 -0
  115. package/src/be/seed-scripts/catalog/github-issues-pull.ts +102 -0
  116. package/src/be/seed-scripts/index.ts +18 -0
  117. package/src/be/seed-skills/index.ts +7 -0
  118. package/src/http/active-sessions.ts +26 -18
  119. package/src/http/agents.ts +94 -27
  120. package/src/http/api-keys.ts +91 -17
  121. package/src/http/approval-requests.ts +87 -10
  122. package/src/http/apps.ts +361 -53
  123. package/src/http/assets.ts +165 -31
  124. package/src/http/budgets.ts +7 -5
  125. package/src/http/codex-oauth-keep-warm.ts +21 -3
  126. package/src/http/config.ts +46 -16
  127. package/src/http/context.ts +33 -6
  128. package/src/http/ecosystem.ts +20 -6
  129. package/src/http/events.ts +24 -9
  130. package/src/http/favorites.ts +20 -6
  131. package/src/http/fs.ts +60 -17
  132. package/src/http/heartbeat.ts +18 -4
  133. package/src/http/inbox-state.ts +12 -6
  134. package/src/http/integrations.ts +25 -5
  135. package/src/http/kv.ts +36 -15
  136. package/src/http/mcp-bridge.ts +5 -1
  137. package/src/http/mcp-oauth.ts +332 -17
  138. package/src/http/mcp-servers.ts +56 -18
  139. package/src/http/memory.ts +282 -29
  140. package/src/http/metrics.ts +80 -17
  141. package/src/http/oauth-callback.ts +10 -4
  142. package/src/http/oauth-generic.ts +5 -1
  143. package/src/http/oauth-locks.ts +6 -3
  144. package/src/http/openapi.ts +57 -9
  145. package/src/http/page-proxy.ts +25 -5
  146. package/src/http/pages-public.ts +37 -20
  147. package/src/http/pages.ts +93 -28
  148. package/src/http/poll.ts +89 -5
  149. package/src/http/pricing.ts +9 -6
  150. package/src/http/prompt-templates.ts +85 -21
  151. package/src/http/route-def.ts +67 -4
  152. package/src/http/schedules.ts +63 -14
  153. package/src/http/script-connection-proxy.ts +30 -4
  154. package/src/http/script-connections.ts +393 -43
  155. package/src/http/script-runs.ts +73 -19
  156. package/src/http/scripts.ts +257 -39
  157. package/src/http/session-data.ts +87 -14
  158. package/src/http/sessions.ts +80 -5
  159. package/src/http/skills.ts +115 -35
  160. package/src/http/stats.ts +94 -13
  161. package/src/http/task-templates.ts +6 -4
  162. package/src/http/tasks.ts +230 -61
  163. package/src/http/trackers/jira.ts +90 -30
  164. package/src/http/trackers/linear.ts +46 -12
  165. package/src/http/users.ts +162 -25
  166. package/src/http/utils.ts +12 -2
  167. package/src/http/webhooks.ts +30 -16
  168. package/src/http/workflow-events.ts +18 -5
  169. package/src/http/workflows.ts +128 -39
  170. package/src/http/x.ts +37 -6
  171. package/src/oauth/ensure-mcp-token.ts +16 -1
  172. package/src/oauth/mcp-wrapper.ts +281 -26
  173. package/src/providers/pi-mono-adapter.ts +1 -1
  174. package/src/scripts-runtime/credential-broker/README.md +1 -1
  175. package/src/scripts-runtime/credential-broker/fetch-patch.ts +12 -2
  176. package/src/scripts-runtime/sdk-allowlist.ts +1 -0
  177. package/src/scripts-runtime/stdlib/fetch.ts +6 -0
  178. package/src/scripts-runtime/types/stdlib.d.ts +1 -0
  179. package/src/scripts-runtime/types/swarm-sdk.d.ts +1 -0
  180. package/src/server.ts +2 -0
  181. package/src/tests/apps-rbac.test.ts +110 -0
  182. package/src/tests/apps-spike2.test.ts +4 -1
  183. package/src/tests/apps-sync-engine.test.ts +2540 -0
  184. package/src/tests/apps-sync.test.ts +1627 -0
  185. package/src/tests/asset-key-api.test.ts +48 -2
  186. package/src/tests/asset-key-invariants.test.ts +92 -0
  187. package/src/tests/asset-key-migration.test.ts +174 -8
  188. package/src/tests/asset-key-normalization.test.ts +2 -0
  189. package/src/tests/credential-broker.test.ts +27 -2
  190. package/src/tests/mcp-oauth-dcr-reuse.test.ts +743 -0
  191. package/src/tests/mcp-oauth-ensure-token.test.ts +45 -0
  192. package/src/tests/mcp-oauth-manual-client.test.ts +135 -2
  193. package/src/tests/mcp-oauth-queries.test.ts +153 -0
  194. package/src/tests/mcp-oauth-wrapper.test.ts +432 -0
  195. package/src/tests/memory-http-recall-gating.test.ts +2 -0
  196. package/src/tests/openapi-response-contract.test.ts +138 -0
  197. package/src/tests/rbac-admission-e2e.test.ts +68 -0
  198. package/src/tests/script-connections.test.ts +3 -0
  199. package/src/tests/scripts-runtime-fetch.test.ts +121 -0
  200. package/src/tests/scripts-runtime-secret-egress.test.ts +4 -2
  201. package/src/tests/seed-scripts.test.ts +2 -2
  202. package/src/tests/swarm-tool-result-gate.test.ts +32 -0
  203. package/src/tests/task-working-dir.test.ts +8 -4
  204. package/src/tests/whoami-route.test.ts +127 -0
  205. package/src/tests/workflow-engine-v2.test.ts +80 -7
  206. package/src/tests/workflow-swarm-script.test.ts +104 -0
  207. package/src/tools/app-get.ts +10 -2
  208. package/src/tools/app-rollback.ts +1 -0
  209. package/src/tools/app-sync.ts +112 -0
  210. package/src/tools/script-common.ts +20 -0
  211. package/src/tools/tool-config.ts +2 -1
  212. package/src/tools/utils.ts +11 -0
  213. package/src/tools/workflows/create-workflow.ts +4 -1
  214. package/src/types.ts +1394 -1206
  215. package/src/utils/template.ts +8 -1
  216. package/src/utils/zod-openapi.ts +11 -0
  217. package/src/workflows/engine.ts +27 -4
  218. package/src/workflows/executors/swarm-script.ts +28 -0
  219. package/src/workflows/retry-poller.ts +19 -2
  220. package/templates/skills/apps/content.md +94 -5
  221. package/templates/skills/asset-namespaces/content.md +2 -2
  222. package/templates/skills/scheduled-task-resilience/SKILL.md +102 -0
  223. package/templates/skills/scheduled-task-resilience/config.json +14 -0
  224. package/templates/skills/scheduled-task-resilience/content.md +97 -0
  225. package/dist/cli-7wmybrt5.js +0 -115
  226. package/dist/cli-d91pvmq1.js +0 -14
@@ -19,9 +19,16 @@ export interface DeepInterpolateOptions {
19
19
  preserveRawTokens?: boolean;
20
20
  }
21
21
 
22
+ const INTERPOLATION_TOKEN_RE = /\{\{([^}]+)\}\}/g;
23
+
24
+ /** Return the normalized paths referenced by every {{path}} token in a string. */
25
+ export function findInterpolationTokens(template: string): string[] {
26
+ return Array.from(template.matchAll(INTERPOLATION_TOKEN_RE), (match) => match[1]!.trim());
27
+ }
28
+
22
29
  export function interpolate(template: string, ctx: Record<string, unknown>): InterpolateResult {
23
30
  const unresolved: string[] = [];
24
- const result = template.replace(/\{\{([^}]+)\}\}/g, (_match, path: string) => {
31
+ const result = template.replace(INTERPOLATION_TOKEN_RE, (_match, path: string) => {
25
32
  const keys = path.trim().split(".");
26
33
  let value: unknown = ctx;
27
34
  for (const key of keys) {
@@ -0,0 +1,11 @@
1
+ import { extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi";
2
+ import * as z from "zod";
3
+
4
+ // Patches the zod prototype with `.openapi()` BEFORE any schema module that
5
+ // names components evaluates. Files that call `.openapi("Name")` on their
6
+ // schemas (src/types.ts and friends) must import z from HERE, not from "zod",
7
+ // so the extension is guaranteed to have run — importing from "zod" directly
8
+ // works only if some other module happened to load this one first.
9
+ extendZodWithOpenApi(z);
10
+
11
+ export { z };
@@ -499,10 +499,20 @@ async function executeStep(
499
499
  }
500
500
 
501
501
  // 4. Deep-interpolate config using local context (not global ctx)
502
- const { value: interpolatedValue, unresolved } = interpolateNodeConfig(node, interpolationCtx);
502
+ const {
503
+ value: interpolatedValue,
504
+ unresolved,
505
+ scriptBodyUnresolved,
506
+ } = interpolateNodeConfig(node, interpolationCtx);
503
507
  const interpolatedConfig = interpolatedValue as Record<string, unknown>;
504
508
  const executionCtx: Record<string, unknown> = { ...ctx, ...interpolationCtx };
505
509
 
510
+ if (scriptBodyUnresolved && scriptBodyUnresolved.length > 0) {
511
+ const errorMsg = scriptBodyInterpolationError(node.id, scriptBodyUnresolved);
512
+ checkpointStepFailure(runId, stepId, errorMsg, 0);
513
+ return { outcome: "failed", successors: [] };
514
+ }
515
+
506
516
  if (unresolved.length > 0) {
507
517
  console.warn(
508
518
  `[workflow] Step ${node.id}: unresolved interpolation tokens: ${unresolved.join(", ")}`,
@@ -797,7 +807,7 @@ function buildScriptBodyCtx(
797
807
  export function interpolateNodeConfig(
798
808
  node: Pick<WorkflowNode, "type" | "config" | "inputs">,
799
809
  interpolationCtx: Record<string, unknown>,
800
- ): { value: unknown; unresolved: string[] } {
810
+ ): { value: unknown; unresolved: string[]; scriptBodyUnresolved?: string[] } {
801
811
  if (node.type === "foreach" && Object.hasOwn(node.config, "over")) {
802
812
  const { over, body, ...configWithoutOverAndBody } = node.config;
803
813
  const configResult = deepInterpolate(configWithoutOverAndBody, interpolationCtx);
@@ -829,15 +839,19 @@ export function interpolateNodeConfig(
829
839
  // (`{{someNode.stdout}}`) are LLM- or script-generated text with the same
830
840
  // problem. Dynamic per-run values must flow through `args` instead, which
831
841
  // are passed as separate argv elements (data), not spliced into source text.
832
- // Excluded tokens resolve to "" and are reported in `unresolved`.
842
+ // Preserve the original source when a body token cannot be resolved. The
843
+ // caller fails the node before execution and reports the token explicitly;
844
+ // returning partially blanked source here would recreate the silent-green
845
+ // failure this guard is intended to prevent.
833
846
  const scriptResult = deepInterpolate(script, buildScriptBodyCtx(node, interpolationCtx));
834
847
 
835
848
  return {
836
849
  value: {
837
850
  ...(restResult.value as Record<string, unknown>),
838
- script: scriptResult.value,
851
+ script: scriptResult.unresolved.length > 0 ? script : scriptResult.value,
839
852
  },
840
853
  unresolved: [...restResult.unresolved, ...scriptResult.unresolved],
854
+ scriptBodyUnresolved: scriptResult.unresolved,
841
855
  };
842
856
  }
843
857
 
@@ -858,6 +872,15 @@ export function interpolateNodeConfig(
858
872
  };
859
873
  }
860
874
 
875
+ export function scriptBodyInterpolationError(nodeId: string, tokens: string[]): string {
876
+ const renderedTokens = [...new Set(tokens)].map((token) => `{{${token}}}`).join(", ");
877
+ return (
878
+ `Script body interpolation failed for node "${nodeId}": unresolved or disallowed token(s): ${renderedTokens}. ` +
879
+ "Executable source may interpolate only input, workflow, swarm, or run values. " +
880
+ "Pass dynamic trigger or upstream values through config.args and read them from argv."
881
+ );
882
+ }
883
+
861
884
  // ─── Helpers ───────────────────────────────────────────────
862
885
 
863
886
  function timeoutPromise(ms: number): Promise<never> {
@@ -12,6 +12,7 @@ import {
12
12
  } from "../../scripts-runtime/executors/types";
13
13
  import { runScript } from "../../scripts-runtime/loader";
14
14
  import type { ExecutorMeta } from "../../types";
15
+ import { findInterpolationTokens } from "../../utils/template";
15
16
  import { BaseExecutor, type ExecutorResult } from "./base";
16
17
 
17
18
  export const SWARM_SCRIPT_DEFAULT_TIMEOUT_MS = DEFAULT_SCRIPT_RESOURCES.wallClockMs;
@@ -47,6 +48,19 @@ export const SwarmScriptOutputSchema = z.object({
47
48
  type SwarmScriptConfig = z.infer<typeof SwarmScriptConfigSchema>;
48
49
  type SwarmScriptOutput = z.infer<typeof SwarmScriptOutputSchema>;
49
50
 
51
+ const WORKFLOW_INTERPOLATION_ROOTS = new Set(["trigger", "input", "workflow", "swarm", "run"]);
52
+
53
+ function findWorkflowSourceTokens(
54
+ source: string,
55
+ inputCtx: Readonly<Record<string, unknown>> | undefined,
56
+ ): string[] {
57
+ const roots = new Set([...WORKFLOW_INTERPOLATION_ROOTS, ...Object.keys(inputCtx ?? {})]);
58
+ return findInterpolationTokens(source).filter((token) => {
59
+ const root = token.split(".")[0];
60
+ return root !== undefined && roots.has(root);
61
+ });
62
+ }
63
+
50
64
  export class SwarmScriptExecutor extends BaseExecutor<
51
65
  typeof SwarmScriptConfigSchema,
52
66
  typeof SwarmScriptOutputSchema
@@ -76,6 +90,20 @@ export class SwarmScriptExecutor extends BaseExecutor<
76
90
  return { status: "failed", error: resolved.error };
77
91
  }
78
92
 
93
+ // Catalog source is never workflow-interpolated. Only reject tokens whose
94
+ // root is a source available to this node (builtins or declared aliases);
95
+ // other mustache syntax is literal script data.
96
+ const sourceTokens = findWorkflowSourceTokens(resolved.source, meta.inputCtx);
97
+ if (sourceTokens.length > 0) {
98
+ const renderedTokens = [...new Set(sourceTokens)].map((token) => `{{${token}}}`).join(", ");
99
+ return {
100
+ status: "failed",
101
+ error:
102
+ `swarm-script body interpolation failed for node "${meta.nodeId}": unresolved token(s): ${renderedTokens}. ` +
103
+ "Named script source is not workflow-interpolated; pass dynamic values through config.args and read them from args.",
104
+ };
105
+ }
106
+
79
107
  const credentials = await buildScriptCredentialBindingsWithFailures({
80
108
  agentId: agentId ?? undefined,
81
109
  });
@@ -8,7 +8,12 @@ import {
8
8
  import type { RetryPolicy } from "../types";
9
9
  import { checkpointStep, checkpointStepFailure, checkpointStepWaiting } from "./checkpoint";
10
10
  import { getSuccessors } from "./definition";
11
- import { buildNodeInterpolationCtx, interpolateNodeConfig, walkGraph } from "./engine";
11
+ import {
12
+ buildNodeInterpolationCtx,
13
+ interpolateNodeConfig,
14
+ scriptBodyInterpolationError,
15
+ walkGraph,
16
+ } from "./engine";
12
17
  import type { AsyncExecutorResult } from "./executors/base";
13
18
  import type { ExecutorRegistry } from "./executors/registry";
14
19
  import { runStepValidation } from "./validation";
@@ -71,7 +76,19 @@ export function startRetryPoller(registry: ExecutorRegistry, intervalMs = 5000):
71
76
  // foreach with `over: "{{items}}"` burns all its retries on schema
72
77
  // rejections without ever re-dispatching.
73
78
  const inputCtx = buildNodeInterpolationCtx(node, ctx);
74
- const { value: interpolatedValue } = interpolateNodeConfig(node, inputCtx);
79
+ const { value: interpolatedValue, scriptBodyUnresolved } = interpolateNodeConfig(
80
+ node,
81
+ inputCtx,
82
+ );
83
+ if (scriptBodyUnresolved && scriptBodyUnresolved.length > 0) {
84
+ checkpointStepFailure(
85
+ run.id,
86
+ step.id,
87
+ scriptBodyInterpolationError(node.id, scriptBodyUnresolved),
88
+ step.retryCount,
89
+ );
90
+ continue;
91
+ }
75
92
  const interpolatedConfig = interpolatedValue as Record<string, unknown>;
76
93
 
77
94
  // Get executor and re-run
@@ -21,6 +21,10 @@ For every edit: `app-get` -> modify the smallest coherent subtree -> `app-patch`
21
21
 
22
22
  `app-patch` can also change `name`; omit `description` to preserve it or pass `description: null` to clear it.
23
23
 
24
+ ### Reading an app's callable surface
25
+
26
+ One `app-get` returns everything needed to drive an app you did not write: `definition.queries` are runnable with `app-query` (supply every `$param` the query references), and `definition.actions` are invocable with `POST /api/apps/<id>/actions/<name>` (a `script` action runs its script, a `task` action spawns a task, a `sync` action refreshes sources). Read a query's `$param` placeholders and an action's kind/args from the definition before calling — do not guess parameter names.
27
+
24
28
  ## Safe schema evolution and rollback
25
29
 
26
30
  Prefer **hiding** a column over deleting it. A hidden column's values stay on every existing row, so an unhide or rollback can make it visible again. Hiding is metadata-only: hidden columns cannot be used in queries, page bindings, `app.mutate` values, or new row writes, and `required` is ignored while hidden. Row GETs still expose the historical value for backward compatibility.
@@ -43,6 +47,10 @@ Schema-changing writes can carry this sibling `migration` object:
43
47
 
44
48
  Use `set` to backfill a changed column, `from` with optional `map`/`else` to derive it from one existing (including hidden) column, `coerce` for a kind change, and `purge` only for explicit destruction. Non-purge directives target a column changed in that same write. The server fails loudly before writing when a lossy change needs a directive: inspect every returned `issues[]` item, including its per-value row counts, then choose an explicit policy and retry. Never guess a migration just to get a 200.
45
49
 
50
+ **Source lifecycle** rides the same engine with the same all-or-nothing guarantees. Free: adding a source, adding a NEW column with a `source` binding, binding an existing column that holds zero values, changing a binding's `field`/`transform`, and changing a source's `args`/`config`/`connection`/`scriptId` (window changes churn staleness on the next pass — expect `stale` flips when narrowing). Rejected with a path-bearing issue: changing `joinKey` (immutable — remove the source and add it again), changing `connector` while the source owns rows (row count in the message), and binding an existing column that already holds values (purge it — or add a fresh column — and bind that instead; a sync pass would otherwise overwrite the data). Removing a source **detaches** its rows: values survive as ordinary columns, the `source`/`syncedAt`/`stale` envelope is stripped, and the report counts them in `detachedRows`. A patch removing a source must drop its column bindings in the same call — a dangling `source.of` is rejected before anything is written.
51
+
52
+ Sync never adopts rows it does not own: a pulled record whose join key matches an unowned row creates a second, source-owned row. Adding a source over a join-key column that already holds values therefore duplicates those records on the first pass — and remove-source → re-add duplicates for the same reason, because detached rows keep their values. To convert a hand-maintained model into a synced one, purge the old rows first or use a fresh join-key column.
53
+
46
54
  For example, split a legacy `flag` into `priority` and `status` without losing data in one patch: add the two columns, map both from `flag`, update every affected query/page binding, then hide `flag`.
47
55
 
48
56
  ```json
@@ -66,7 +74,7 @@ For example, split a legacy `flag` into `priority` and `status` without losing d
66
74
  }
67
75
  ```
68
76
 
69
- Every schema migration returns `{ scanned, backfilled, coerced, mapped, elsed, purgedValues, idxRebuilt, orphanFields }`. Read `orphanFields`: it reports extra row fields that predate or no longer appear in the definition; they are preserved until an explicit purge. `schemaVersion` is server-managed: never set it in an input patch or try to use it as application data.
77
+ Every schema migration returns `{ scanned, backfilled, coerced, mapped, elsed, purgedValues, idxRebuilt, detachedRows, orphanFields }`. Read `orphanFields`: it reports extra row fields that predate or no longer appear in the definition; they are preserved until an explicit purge. `schemaVersion` is server-managed: never set it in an input patch or try to use it as application data.
70
78
 
71
79
  Every successful definition write snapshots the previous definition. Use `app-history` to select a version, `app-diff({ appId, from: <version> })` to inspect it against CURRENT, then `app-rollback({ appId, version: <version>, migration? })` to restore it. Rollback is a forward migration over live rows, not row-level time travel: it creates a new pre-rollback snapshot and may reject a lossy restore with the same migration directives and counts. A rejected rollback changes nothing.
72
80
 
@@ -171,7 +179,87 @@ Each column is `{ "kind": ..., "required"?: boolean, "default"?: ..., "index"?:
171
179
  | `date` | ISO-8601 string; never indexed. |
172
180
  | `enum` | String from the non-empty unique `enum` list; always indexed, without `index: true`. |
173
181
 
174
- `default` must match the column kind (and be one of the enum values). `required: true` rejects missing/null values unless a default supplies the value. `id`, `createdAt`, `updatedAt`, `createdBy`, and `updatedBy` are system columns and reserved names. Rows always expose `id`/`createdAt`/`updatedAt`; `createdBy`/`updatedBy` record the acting principal (`user:<id>`, `agent:<id>`, or `operator`) and are system-managed on every row write.
182
+ `default` must match the column kind (and be one of the enum values). `required: true` rejects missing/null values unless a default supplies the value. `id`, `createdAt`, `updatedAt`, `createdBy`, `updatedBy` — and the sync envelope `source`, `syncedAt`, `stale` — are system columns and reserved names. An app created before the sync envelope that declared ordinary columns with these names surfaces `definitionError` after upgrade until they are renamed: patch in a fresh column with a `{from}` migration directive, then purge the old one. Rows always expose `id`/`createdAt`/`updatedAt`; `createdBy`/`updatedBy` record the acting principal (`user:<id>`, `agent:<id>`, `operator`, or `sync:<source>` for engine writes) and are system-managed on every row write.
183
+
184
+ ### Sources and sync
185
+
186
+ A model may declare up to 4 named `sources` — one-way inbound projections of an external system into its rows. The `script` connector is THE way to bring external data in (GitHub, Linear, any API): a saved script owns the fetch, its auth placeholders, and its paging. `swarm-tasks` is the one native connector, projecting this swarm's own task pool (`config`: `status`/`tags` comma-lists, `agentId`, `assetKey` namespace prefix, `limit` up to 200 — default 100, `includeHeartbeat`). Synced rows are readable by EVERY principal with `app.use` on the app; an unscoped `swarm-tasks` source publishes task prompts (truncated, secret-scrubbed) to all of them, so scope it with `config.assetKey`/`config.agentId` unless that exposure is intended. A user-invoked sync only ever pulls the invoking user's own requested tasks. Script sources run with their run-as identity's credentials — an agent-owned script runs as its owner and only the owner may wire it; an owner-less catalog (global) script runs as the LEAD, so only the lead or the operator can wire or alter such a source (stored references stay valid with their args and connection pinned).
187
+
188
+ ```json
189
+ {
190
+ "models": {
191
+ "issue": {
192
+ "columns": {
193
+ "issueKey": { "kind": "string" },
194
+ "title": { "kind": "string", "source": { "of": "gh", "field": "title" } },
195
+ "openedAt": { "kind": "date", "source": { "of": "gh", "field": "created_at", "transform": "date-parse" } },
196
+ "note": { "kind": "string" }
197
+ },
198
+ "sources": {
199
+ "gh": { "connector": "script", "scriptId": "<script-uuid>", "joinKey": "issueKey", "args": { "repo": "owner/name" }, "connection": "github" }
200
+ }
201
+ }
202
+ }
203
+ }
204
+ ```
205
+
206
+ - **Join key**: each source names a `joinKey` — an owned, non-hidden `string` column carrying the external identity. It must not be `required`, must not declare a `default`, and cannot itself be source-bound. A row belongs to at most ONE source (`row.source`).
207
+ - **Column bindings**: `{ "of": "<sourceName>", "field": "user.login", "transform"? }` projects a (dotted-path) field of the pulled record into the column on every pass. Transforms and their required column kinds: `slug`/`lower`/`upper` → `string`, `cents` → `number` (×100, rounded), `date-parse` → `date` (ISO). A failed transform nulls that one column and warns; it never fails the pass.
208
+ - **Read-only contract**: source-bound columns and the join key reject every direct write (row create/patch/bulk) with a path-bearing issue — mutate them via the source or a sync refresh. Owned columns on the same model stay writable. Every `required` owned column on a sourced model must declare a `default`, because sync-created rows cannot supply it.
209
+ - **Provenance**: synced rows carry `source` (owner), `syncedAt` (last confirmed presence — sortable), and `stale` (true when a complete pull no longer returned the record; clears on reappearance). `syncedAt` advances on every confirmed row WITHOUT touching `updatedAt`; `updatedAt` moves only when projected data actually changed.
210
+ - **`connection`** (optional, script sources): names a connections-system slug the source depends on. It buys write-time validation plus a preflight before every pull; it does not by itself gate egress. Credentials resolve for the sync run-as identity — the script's owner when it has one, else the lead agent — so global connections always work and agent-scoped ones need matching script ownership. The slug is injected into the script's args as `connection`, letting one generic script serve N connections.
211
+
212
+ **The canonical sync-script contract** (every sync script, always):
213
+
214
+ ```ts
215
+ export default async function run(args, ctx) {
216
+ // args = { ...source.args, app: { id }, model: "<model>", source: "<sourceName>", connection?: "<slug>" }
217
+ const records = [{ key: "42", fields: { title: "…", created_at: "…" } }];
218
+ return { records, complete: true }; // or return the bare array — a bare array IS a complete snapshot
219
+ }
220
+ ```
221
+
222
+ Return `Array<{ key, fields }>` or `{ records, complete? }`. `key` is the external identity the engine joins on `joinKey` (numbers are coerced to strings); `fields` is the bag bindings project from. Return `complete: false` whenever the pull window may have missed records — a paged fetch, a hit `limit`, or client-side filtering of a full page. An incomplete pull skips the stale sweep, so rows outside a narrow window are not falsely marked stale; compute `complete` BEFORE any client-side filtering. Hard cap 500 records per pull (over-cap pulls truncate and count as incomplete). An invalid return shape, thrown error, or non-zero exit fails the pass with zero row churn.
223
+
224
+ The seeded catalog script `github-issues-pull` is the worked example (placeholder auth via `[REDACTED:GITHUB_TOKEN]`, PR filtering, `complete` computed before filtering). For scheduled refreshes, point a `targetType: "script"` schedule at the seeded `app-sync-run` with `scriptArgs: { "appId": "<id>", "model"?, "source"? }`.
225
+
226
+ **Three refresh doors, one engine**: `POST /api/apps/<id>/sync` (body `{ "model"?, "source"? }`), a `sync` action kind, and the `app-sync` MCP tool (`ctx.swarm.app_sync` from scripts). `{model?, source?}` fans out to every matching (model × source) pair; each pass reports `{ pulled, created, updated, refreshed, unchanged, markedStale, warnings }`. A pair already syncing in this API process short-circuits with `alreadyRunning: true` instead of pulling twice. Each pair's last-pass status (`lastFinishedAt`, counts, `error?`) comes back as `syncStatus` — keyed `"<model>:<source>"` — on `GET /api/apps/<id>` and in `app-get` data, so UI and agents can render "last synced / last error" without extra calls.
227
+
228
+ ### Freshness and the refresh button
229
+
230
+ Freshness renders with the existing column kinds — no special components. Give the table `{ "key": "syncedAt", "kind": "date" }` and `{ "key": "stale", "kind": "badge", "tones": { "true": "warning" } }` columns, declare a `sync` action, and wire a Button to it:
231
+
232
+ ```json
233
+ {
234
+ "actions": { "refresh": { "kind": "sync" } },
235
+ "pages": {
236
+ "main": {
237
+ "root": "root",
238
+ "elements": {
239
+ "root": { "type": "Container", "props": {}, "children": ["refreshBtn", "issuesTable"] },
240
+ "refreshBtn": {
241
+ "type": "Button",
242
+ "props": { "label": "Refresh" },
243
+ "on": { "press": [{ "action": "app.action", "params": { "name": "refresh" } }] }
244
+ },
245
+ "issuesTable": {
246
+ "type": "Table",
247
+ "props": {
248
+ "data": { "$state": "/queries/allIssues/data" },
249
+ "columns": [
250
+ { "key": "title", "label": "Issue" },
251
+ { "key": "syncedAt", "label": "Synced", "kind": "date" },
252
+ { "key": "stale", "label": "Stale", "kind": "badge", "tones": { "true": "warning" } }
253
+ ]
254
+ }
255
+ }
256
+ }
257
+ }
258
+ }
259
+ }
260
+ ```
261
+
262
+ A `sync` action answers the script-action state shape (`/actions/refresh` → `running` → `ok`/`error`, result under `result.passes`), so the page's queries refetch when it completes. Use `app-query` to READ rows (named queries may filter on `stale`/`source` and sort by `syncedAt`); use `app-sync` to REFRESH sources; never poll a sync door in a loop — schedule it or give people the button.
175
263
 
176
264
  ### Queries
177
265
 
@@ -186,7 +274,7 @@ A named query is:
186
274
  }
187
275
  ```
188
276
 
189
- `filter` is a strict AND of equality checks. A filter may target a declared model column or a system column (`id`, `createdAt`, `updatedAt`, `createdBy`, `updatedBy`) — filtering on `id` is the universal way to select one row for a detail view. A literal value must match the column's kind; date filters compare the stored raw ISO string, not parsed instants. Omit `limit` for the default 200 rows, or set an integer from 1 through 1000. `sort.column` is a model column, `createdAt`, or `updatedAt`; `dir` is `asc` or `desc`. Query runtime state is `{ data, loading, error }` under `/queries/<queryName>`.
277
+ `filter` is a strict AND of equality checks. A filter may target a declared model column or a system column (`id`, `createdAt`, `updatedAt`, `createdBy`, `updatedBy`, and on synced models `source`, `syncedAt`, `stale`) — filtering on `id` is the universal way to select one row for a detail view, and `{ "stale": true }` selects rows a complete pull stopped confirming. A literal value must match the column's kind; date filters compare the stored raw ISO string, not parsed instants. Omit `limit` for the default 200 rows, or set an integer from 1 through 1000. `sort.column` is a model column, `createdAt`, `updatedAt`, or `syncedAt`; `dir` is `asc` or `desc`. Query runtime state is `{ data, loading, error }` under `/queries/<queryName>`.
190
278
 
191
279
  A filter value may instead be exactly `{ "$param": "<name>" }`. At execution, the caller supplies that name and the server coerces its value to the filtered column's kind before equality matching:
192
280
 
@@ -223,7 +311,8 @@ Actions are optional (maximum 20) and are invoked from the page with the `app.ac
223
311
  | Kind | Contract |
224
312
  |---|---|
225
313
  | `script` | `scriptId` must identify an existing script; optional `args` are defaults. Invocation input overrides same-named defaults, and the runtime also supplies `app: { id }`. |
226
- | `task` | `prompt` must be non-empty; omit `agentId` to use default (lead) assignment. Only set `agentId` to a real agent UUID. Invocation `input` is included as context for the task prompt. |
314
+ | `task` | `prompt` must be non-empty; omit `agentId` to use default (lead) assignment. Only set `agentId` to a real agent UUID. Invocation `input` is included as context for the task prompt. Spawned tasks carry asset key `shared/app:<appId>/action:<name>/`, so a `swarm-tasks` source with `config.assetKey: "shared/app:<appId>/"` pulls back the tasks this app spawned. |
315
+ | `sync` | Optional `model` and `source` narrow which (model × source) pairs refresh; omitting both syncs every pair the app declares. At least one pair must exist. Answers the script-kind state shape with per-pass results under `result.passes`. |
227
316
 
228
317
  - Invocation state lands at `/actions/<name>` as `{ status, result?, error?, taskId?, taskStatus? }`, where `status` is `"running"`, `"ok"`, or `"error"`.
229
318
 
@@ -480,7 +569,7 @@ An action-chain step is `{ "action": "<type>", "params": {...} }`. Available act
480
569
  - arrays and scalar values replace;
481
570
  - `null` deletes a key;
482
571
  - reusable-element disambiguation: a patch value for `elements.<name>` containing ONLY the `elements` key merges node-by-node; any other key present (`mode`, `root`, `props`, or `export`) makes it a full element replace — restate every field you want kept. In that node-by-node form, `elements.<name>.elements.<id>: null` deletes one node. Literal null nodes are rejected in a full element replace so they cannot be mistaken for deletions.
483
- - otherwise every supplied `pages.<page>.elements.<id>`, `pages.<page>.params.<param>`, `actions.<name>`, and `models.<name>.columns.<col>` value is atomic and replaces that complete declaration; `null` deletes it.
572
+ - otherwise every supplied `pages.<page>.elements.<id>`, `pages.<page>.params.<param>`, `actions.<name>`, `models.<name>.columns.<col>`, and `models.<name>.sources.<src>` value is atomic and replaces that complete declaration; `null` deletes it (deleting a source detaches its rows — see the source lifecycle above).
484
573
 
485
574
  `pages.<page>: null` deletes a page, except the current `defaultPage` cannot be deleted. `root`, `title`, and top-level `defaultPage` use ordinary merge semantics. A top-level `page` patch is rejected with guidance to patch `pages.<name>` instead.
486
575
 
@@ -1,6 +1,6 @@
1
1
  # Asset Namespaces
2
2
 
3
- Use an asset `key` as directory-like grouping metadata across tasks, workflows, schedules, pages, and mapped provider files. Keep identity separate: entity IDs remain authoritative, task `contextKey` remains routing context, and many assets may intentionally share one key.
3
+ Use an asset `key` as directory-like grouping metadata across tasks, workflows, schedules, pages, apps, scripts, and mapped provider files. Keep identity separate: entity IDs remain authoritative, task `contextKey` remains routing context, and many assets may intentionally share one key.
4
4
 
5
5
  ## Choose a namespace
6
6
 
@@ -29,7 +29,7 @@ Use entity list tools with `key` for an exact namespace or `keyPrefix` for a sub
29
29
  ```js
30
30
  const result = await window.swarmSdk.assets.list({
31
31
  keyPrefix: "shared/releases/",
32
- types: "task,workflow,schedule,page,file",
32
+ types: "task,workflow,schedule,page,app,script,file",
33
33
  limit: 100,
34
34
  });
35
35
  ```
@@ -0,0 +1,102 @@
1
+ ---
2
+ name: scheduled-task-resilience
3
+ description: Guardrails for polling, scheduled jobs, and long-running external operations. Use whenever a task waits on CI, builds, deploys, browser jobs, or another asynchronous API so work survives heartbeat checks without duplicate delivery.
4
+ ---
5
+
6
+ # Scheduled Task Resilience
7
+
8
+ Use these rules whenever a scheduled or regular task polls or waits on a long-running operation. They prevent heartbeat collisions, lost work, duplicate delivery, and sessions that fail after holding an external job open too long.
9
+
10
+ ## Rule 1 — Never use `ScheduleWakeup` with `delaySeconds >= 300`
11
+
12
+ The runtime heartbeat staleness threshold may sit near the same window as the model prompt-cache TTL. Sleeping for five minutes or longer can therefore look like a dead worker and cause the heartbeat sweep to fail the task mid-poll.
13
+
14
+ Instead:
15
+
16
+ - For waits under four minutes, run **one** bounded sleep of at most 240 seconds followed by a **single** status check, as one shell call, then return control to the agent turn.
17
+ - **Never batch sleeps into a single shell invocation.** A loop of five 240-second sleeps blocks the session for twenty minutes with no opportunity to record progress — the same staleness failure `ScheduleWakeup` causes, just spelled differently.
18
+ - Between cycles, record progress as described in Rule 3, then start the next cycle in the next turn.
19
+ - For genuinely long waits such as CI builds, releases, or deploys, prefer a durable workflow or a follow-up task over `ScheduleWakeup`.
20
+
21
+ Aggregate **every** check run before you decide the poll is over. Reading a single row such as `.[0].state` reports one check and can look terminal while another required check is still pending or failing.
22
+
23
+ ```bash
24
+ # ONE cycle per agent turn. Resolve PR_NUMBER from the current task or repository context.
25
+ sleep 240
26
+ if out=$(gh pr checks "$PR_NUMBER" --json name,state,bucket \
27
+ --jq 'if any(.[]; .bucket == "pending") then "PENDING"
28
+ elif any(.[]; .bucket == "fail" or .bucket == "cancel") then "FAILING"
29
+ else "PASSED" end' 2>&1); then
30
+ echo "$out"
31
+ else
32
+ case "$out" in
33
+ *"no checks reported"*) echo "PENDING" ;; # no check contexts yet — not a failure
34
+ *) echo "$out" >&2; exit 1 ;; # real gh error: auth, network, bad PR
35
+ esac
36
+ fi
37
+ ```
38
+
39
+ ⚠️ Do not substitute the exit code for the aggregate here. Plain `gh pr checks <pr>` exits `8` while any check is pending and `1` on failure, but **`--json` suppresses that — it exits `0` even mid-run** (verified on gh 2.97.0). A cycle that adds `--json` and then reads `$?` reports success on a still-pending PR, which is the exact bug the aggregate exists to prevent.
40
+
41
+ ⚠️ Treat `no checks reported` as `PENDING`, not as a failure. Right after a PR is opened or a new commit is pushed, GitHub can briefly report zero check contexts. In that window `gh pr checks` exits `1` and prints `no checks reported on the '<branch>' branch` **before** the `--json` exporter runs, so the aggregate never executes at all (verified on gh 2.97.0). A cycle that treats any non-zero exit as failure will stop polling — or report CI as broken — before the workflows have even appeared. That is why the sample branches on the error text instead of on the exit status alone.
42
+
43
+ Handle each aggregate distinctly: `PASSED` means polling is done. For `PENDING`, call `store-progress`, then run one more cycle in the next turn; cap the number of cycles and hand off to a follow-up task once the total wait approaches the heartbeat staleness threshold. For `FAILING`, stop polling immediately, inspect the failed checks with `gh pr checks <pr> --json name,state,bucket` and the run logs, then report or fix the failure — never sleep again on a failing aggregate.
44
+
45
+ ## Rule 2 — Tag retry tasks with `reboot-retry`
46
+
47
+ Any task automatically retried after a session loss must include `reboot-retry` in its tags. If you recreate a lost task manually, add the same tag so the deployment's boot-sweep logic can identify it correctly.
48
+
49
+ Resolve the original task from the current task's parent or retry metadata. Do not copy a task ID from another incident or deployment.
50
+
51
+ ## Rule 3 — Long polls must store progress every two to three minutes
52
+
53
+ Workers that poll silently can look stale to the heartbeat sweep.
54
+
55
+ After every poll cycle — that is, after each shell call that sleeps and checks once — call `store-progress` with a concrete progress message such as `polling CI status (attempt 3/10)`. Even when the remote state has not changed, the progress update records that the worker is still active. This is only possible because each cycle returns control to the agent turn, which is why Rule 1 forbids batching sleeps.
56
+
57
+ Resolve the active task ID from the current task context. Never embed an agent ID, task ID, organization ID, or other deployment-specific identifier in a reusable polling script.
58
+
59
+ ## Rule 4 — Diagnose repeated scheduled-task failures before retrying
60
+
61
+ Two immediate failures with the same heartbeat-staleness reason usually indicate an infrastructure or lifecycle problem, not a transient job failure. Do not create a third identical attempt.
62
+
63
+ - A worker should use the swarm's escalation channel or task handoff mechanism to report repeated heartbeat termination.
64
+ - A lead should resolve the configured operator escalation destination from deployment or task metadata rather than hardcoding a Slack channel.
65
+ - Include the affected task IDs in the incident report by reading them from the failed task records at runtime.
66
+
67
+ ## Rule 5 — Check for duplicate delivery before posting
68
+
69
+ Concurrent sessions can pick up equivalent scheduled work. Before sending scheduled output to Slack, email, a blog, or another external destination:
70
+
71
+ 1. Query recent tasks using the current schedule ID or schedule tag.
72
+ 2. Search the destination history for an equivalent delivery in the relevant time window.
73
+ 3. If a recent completion already delivered the result, stop and record the duplicate detection with `store-progress`.
74
+
75
+ Derive the schedule ID, destination, and time window from the current task and schedule records. Do not reuse identifiers or recipient addresses copied from another task.
76
+
77
+ ## Rule 6 — After the deliverable ships, complete and exit
78
+
79
+ Do not call `ScheduleWakeup` merely to watch CI, a merge, or another downstream state after the requested deliverable already exists. A suspended post-shipping session can still be reaped and mark successful work as failed.
80
+
81
+ Choose one path:
82
+
83
+ 1. **Workflow-driven task:** call `store-progress` with `status: "completed"` and the deliverable details, then exit. Let the workflow's next node handle downstream state.
84
+ 2. **Human-requested task needing later confirmation:** report the deliverable URL and current downstream status, complete the task, and let the lead or automation create a follow-up if the downstream check fails.
85
+ 3. **Rare in-process wait:** use the single-cycle sleep-then-check pattern from Rule 1, storing progress between cycles. Do not suspend the session with `ScheduleWakeup`, and do not batch sleeps into one shell call.
86
+
87
+ The key distinction is whether work remains. `ScheduleWakeup` is only for a brief wait in the middle of active work; it is not a post-delivery monitoring mechanism.
88
+
89
+ ## Rule 7 — Use fire-then-follow-up for slow external jobs
90
+
91
+ A worker session is not a durable job runner. Polling an external asynchronous API for tens of minutes can outlive the worker heartbeat even when the external job continues successfully.
92
+
93
+ For browser automation, large data pulls, media processing, or any other external job expected to exceed roughly 20 minutes:
94
+
95
+ 1. Start the external actions.
96
+ 2. Store the returned action or task IDs, destination IDs, and a one-line resume recipe with `store-progress`.
97
+ 3. Persist the same recovery state in agent-fs, a durable workflow step, or another deployment-approved store.
98
+ 4. Complete the fire step and run collection, filtering, deduplication, and delivery in a fresh follow-up task.
99
+
100
+ Resolve every identifier from the external API response and current task metadata. Never bake a prior run's IDs into the skill or follow-up template.
101
+
102
+ When historical incident detail is useful, search the deployment's memory registry for heartbeat-reaper and long-running external-poll records instead of depending on copied task IDs or worker names.
@@ -0,0 +1,14 @@
1
+ {
2
+ "kind": "skill",
3
+ "name": "scheduled-task-resilience",
4
+ "displayName": "Scheduled Task Resilience",
5
+ "slug": "scheduled-task-resilience",
6
+ "title": "Scheduled Task Resilience",
7
+ "description": "Guardrails for polling, scheduled jobs, and long-running external operations. Use whenever a task waits on CI, builds, deploys, browser jobs, or another asynchronous API so work survives heartbeat checks without duplicate delivery.",
8
+ "version": "1.0.0",
9
+ "category": "skills",
10
+ "placeholders": [],
11
+ "runAllSeedersCandidate": true,
12
+ "systemDefault": true,
13
+ "tags": ["scheduling", "polling", "resilience"]
14
+ }
@@ -0,0 +1,97 @@
1
+ # Scheduled Task Resilience
2
+
3
+ Use these rules whenever a scheduled or regular task polls or waits on a long-running operation. They prevent heartbeat collisions, lost work, duplicate delivery, and sessions that fail after holding an external job open too long.
4
+
5
+ ## Rule 1 — Never use `ScheduleWakeup` with `delaySeconds >= 300`
6
+
7
+ The runtime heartbeat staleness threshold may sit near the same window as the model prompt-cache TTL. Sleeping for five minutes or longer can therefore look like a dead worker and cause the heartbeat sweep to fail the task mid-poll.
8
+
9
+ Instead:
10
+
11
+ - For waits under four minutes, run **one** bounded sleep of at most 240 seconds followed by a **single** status check, as one shell call, then return control to the agent turn.
12
+ - **Never batch sleeps into a single shell invocation.** A loop of five 240-second sleeps blocks the session for twenty minutes with no opportunity to record progress — the same staleness failure `ScheduleWakeup` causes, just spelled differently.
13
+ - Between cycles, record progress as described in Rule 3, then start the next cycle in the next turn.
14
+ - For genuinely long waits such as CI builds, releases, or deploys, prefer a durable workflow or a follow-up task over `ScheduleWakeup`.
15
+
16
+ Aggregate **every** check run before you decide the poll is over. Reading a single row such as `.[0].state` reports one check and can look terminal while another required check is still pending or failing.
17
+
18
+ ```bash
19
+ # ONE cycle per agent turn. Resolve PR_NUMBER from the current task or repository context.
20
+ sleep 240
21
+ if out=$(gh pr checks "$PR_NUMBER" --json name,state,bucket \
22
+ --jq 'if any(.[]; .bucket == "pending") then "PENDING"
23
+ elif any(.[]; .bucket == "fail" or .bucket == "cancel") then "FAILING"
24
+ else "PASSED" end' 2>&1); then
25
+ echo "$out"
26
+ else
27
+ case "$out" in
28
+ *"no checks reported"*) echo "PENDING" ;; # no check contexts yet — not a failure
29
+ *) echo "$out" >&2; exit 1 ;; # real gh error: auth, network, bad PR
30
+ esac
31
+ fi
32
+ ```
33
+
34
+ ⚠️ Do not substitute the exit code for the aggregate here. Plain `gh pr checks <pr>` exits `8` while any check is pending and `1` on failure, but **`--json` suppresses that — it exits `0` even mid-run** (verified on gh 2.97.0). A cycle that adds `--json` and then reads `$?` reports success on a still-pending PR, which is the exact bug the aggregate exists to prevent.
35
+
36
+ ⚠️ Treat `no checks reported` as `PENDING`, not as a failure. Right after a PR is opened or a new commit is pushed, GitHub can briefly report zero check contexts. In that window `gh pr checks` exits `1` and prints `no checks reported on the '<branch>' branch` **before** the `--json` exporter runs, so the aggregate never executes at all (verified on gh 2.97.0). A cycle that treats any non-zero exit as failure will stop polling — or report CI as broken — before the workflows have even appeared. That is why the sample branches on the error text instead of on the exit status alone.
37
+
38
+ Handle each aggregate distinctly: `PASSED` means polling is done. For `PENDING`, call `store-progress`, then run one more cycle in the next turn; cap the number of cycles and hand off to a follow-up task once the total wait approaches the heartbeat staleness threshold. For `FAILING`, stop polling immediately, inspect the failed checks with `gh pr checks <pr> --json name,state,bucket` and the run logs, then report or fix the failure — never sleep again on a failing aggregate.
39
+
40
+ ## Rule 2 — Tag retry tasks with `reboot-retry`
41
+
42
+ Any task automatically retried after a session loss must include `reboot-retry` in its tags. If you recreate a lost task manually, add the same tag so the deployment's boot-sweep logic can identify it correctly.
43
+
44
+ Resolve the original task from the current task's parent or retry metadata. Do not copy a task ID from another incident or deployment.
45
+
46
+ ## Rule 3 — Long polls must store progress every two to three minutes
47
+
48
+ Workers that poll silently can look stale to the heartbeat sweep.
49
+
50
+ After every poll cycle — that is, after each shell call that sleeps and checks once — call `store-progress` with a concrete progress message such as `polling CI status (attempt 3/10)`. Even when the remote state has not changed, the progress update records that the worker is still active. This is only possible because each cycle returns control to the agent turn, which is why Rule 1 forbids batching sleeps.
51
+
52
+ Resolve the active task ID from the current task context. Never embed an agent ID, task ID, organization ID, or other deployment-specific identifier in a reusable polling script.
53
+
54
+ ## Rule 4 — Diagnose repeated scheduled-task failures before retrying
55
+
56
+ Two immediate failures with the same heartbeat-staleness reason usually indicate an infrastructure or lifecycle problem, not a transient job failure. Do not create a third identical attempt.
57
+
58
+ - A worker should use the swarm's escalation channel or task handoff mechanism to report repeated heartbeat termination.
59
+ - A lead should resolve the configured operator escalation destination from deployment or task metadata rather than hardcoding a Slack channel.
60
+ - Include the affected task IDs in the incident report by reading them from the failed task records at runtime.
61
+
62
+ ## Rule 5 — Check for duplicate delivery before posting
63
+
64
+ Concurrent sessions can pick up equivalent scheduled work. Before sending scheduled output to Slack, email, a blog, or another external destination:
65
+
66
+ 1. Query recent tasks using the current schedule ID or schedule tag.
67
+ 2. Search the destination history for an equivalent delivery in the relevant time window.
68
+ 3. If a recent completion already delivered the result, stop and record the duplicate detection with `store-progress`.
69
+
70
+ Derive the schedule ID, destination, and time window from the current task and schedule records. Do not reuse identifiers or recipient addresses copied from another task.
71
+
72
+ ## Rule 6 — After the deliverable ships, complete and exit
73
+
74
+ Do not call `ScheduleWakeup` merely to watch CI, a merge, or another downstream state after the requested deliverable already exists. A suspended post-shipping session can still be reaped and mark successful work as failed.
75
+
76
+ Choose one path:
77
+
78
+ 1. **Workflow-driven task:** call `store-progress` with `status: "completed"` and the deliverable details, then exit. Let the workflow's next node handle downstream state.
79
+ 2. **Human-requested task needing later confirmation:** report the deliverable URL and current downstream status, complete the task, and let the lead or automation create a follow-up if the downstream check fails.
80
+ 3. **Rare in-process wait:** use the single-cycle sleep-then-check pattern from Rule 1, storing progress between cycles. Do not suspend the session with `ScheduleWakeup`, and do not batch sleeps into one shell call.
81
+
82
+ The key distinction is whether work remains. `ScheduleWakeup` is only for a brief wait in the middle of active work; it is not a post-delivery monitoring mechanism.
83
+
84
+ ## Rule 7 — Use fire-then-follow-up for slow external jobs
85
+
86
+ A worker session is not a durable job runner. Polling an external asynchronous API for tens of minutes can outlive the worker heartbeat even when the external job continues successfully.
87
+
88
+ For browser automation, large data pulls, media processing, or any other external job expected to exceed roughly 20 minutes:
89
+
90
+ 1. Start the external actions.
91
+ 2. Store the returned action or task IDs, destination IDs, and a one-line resume recipe with `store-progress`.
92
+ 3. Persist the same recovery state in agent-fs, a durable workflow step, or another deployment-approved store.
93
+ 4. Complete the fire step and run collection, filtering, deduplication, and delivery in a fresh follow-up task.
94
+
95
+ Resolve every identifier from the external API response and current task metadata. Never bake a prior run's IDs into the skill or follow-up template.
96
+
97
+ When historical incident detail is useful, search the deployment's memory registry for heartbeat-reaper and long-running external-poll records instead of depending on copied task IDs or worker names.