@stigmer/runner 3.12.4 → 3.12.5

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 (146) hide show
  1. package/dist/.build-fingerprint +1 -1
  2. package/dist/activities/execute-cursor/cursor-mcp-config.d.ts +45 -0
  3. package/dist/activities/execute-cursor/cursor-mcp-config.js +76 -0
  4. package/dist/activities/execute-cursor/cursor-mcp-config.js.map +1 -0
  5. package/dist/activities/execute-cursor/index.d.ts +10 -1
  6. package/dist/activities/execute-cursor/index.js +47 -29
  7. package/dist/activities/execute-cursor/index.js.map +1 -1
  8. package/dist/activities/execute-cursor/prompt-builder.d.ts +13 -2
  9. package/dist/activities/execute-cursor/prompt-builder.js +11 -0
  10. package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
  11. package/dist/activities/execute-cursor/session-lifecycle.d.ts +1 -1
  12. package/dist/activities/execute-cursor/skill-resolver.d.ts +2 -21
  13. package/dist/activities/execute-cursor/skill-resolver.js +5 -108
  14. package/dist/activities/execute-cursor/skill-resolver.js.map +1 -1
  15. package/dist/activities/execute-deep-agent/approval-file-change.d.ts +9 -0
  16. package/dist/activities/execute-deep-agent/approval-file-change.js +2 -1
  17. package/dist/activities/execute-deep-agent/approval-file-change.js.map +1 -1
  18. package/dist/activities/execute-deep-agent/cas-capture-backend.d.ts +18 -1
  19. package/dist/activities/execute-deep-agent/cas-capture-backend.js +20 -3
  20. package/dist/activities/execute-deep-agent/cas-capture-backend.js.map +1 -1
  21. package/dist/activities/execute-deep-agent/index.js +57 -5
  22. package/dist/activities/execute-deep-agent/index.js.map +1 -1
  23. package/dist/activities/execute-deep-agent/prompt-builder.d.ts +9 -0
  24. package/dist/activities/execute-deep-agent/prompt-builder.js +10 -0
  25. package/dist/activities/execute-deep-agent/prompt-builder.js.map +1 -1
  26. package/dist/activities/execute-deep-agent/setup.js +17 -16
  27. package/dist/activities/execute-deep-agent/setup.js.map +1 -1
  28. package/dist/activities/execute-deep-agent/subagent-transformer.js +17 -11
  29. package/dist/activities/execute-deep-agent/subagent-transformer.js.map +1 -1
  30. package/dist/activities/execute-deep-agent/subagent-wiring.d.ts +3 -3
  31. package/dist/index.d.ts +1 -1
  32. package/dist/index.js +1 -1
  33. package/dist/middleware/path-normalization.d.ts +49 -49
  34. package/dist/middleware/path-normalization.js +73 -84
  35. package/dist/middleware/path-normalization.js.map +1 -1
  36. package/dist/middleware/types.d.ts +3 -3
  37. package/dist/runner-manager.d.ts +1 -1
  38. package/dist/runner-manager.js +22 -15
  39. package/dist/runner-manager.js.map +1 -1
  40. package/dist/runner.js +11 -0
  41. package/dist/runner.js.map +1 -1
  42. package/dist/shared/args-preview.d.ts +8 -0
  43. package/dist/shared/args-preview.js +14 -3
  44. package/dist/shared/args-preview.js.map +1 -1
  45. package/dist/shared/artifact-storage.d.ts +11 -1
  46. package/dist/shared/artifact-storage.js +10 -1
  47. package/dist/shared/artifact-storage.js.map +1 -1
  48. package/dist/shared/declared-preferences.d.ts +47 -0
  49. package/dist/shared/declared-preferences.js +64 -0
  50. package/dist/shared/declared-preferences.js.map +1 -0
  51. package/dist/shared/mcp-enabled-tools.d.ts +2 -3
  52. package/dist/shared/mcp-enabled-tools.js +2 -3
  53. package/dist/shared/mcp-enabled-tools.js.map +1 -1
  54. package/dist/shared/mcp-resolver.d.ts +20 -15
  55. package/dist/shared/mcp-resolver.js +11 -12
  56. package/dist/shared/mcp-resolver.js.map +1 -1
  57. package/dist/shared/placeholder-resolver.d.ts +9 -2
  58. package/dist/shared/placeholder-resolver.js +9 -2
  59. package/dist/shared/placeholder-resolver.js.map +1 -1
  60. package/dist/shared/plan-mode-permissions.d.ts +26 -46
  61. package/dist/shared/plan-mode-permissions.js +27 -55
  62. package/dist/shared/plan-mode-permissions.js.map +1 -1
  63. package/dist/shared/skill-mount.d.ts +89 -0
  64. package/dist/shared/skill-mount.js +142 -0
  65. package/dist/shared/skill-mount.js.map +1 -0
  66. package/dist/shared/skill-writer.d.ts +28 -26
  67. package/dist/shared/skill-writer.js +79 -102
  68. package/dist/shared/skill-writer.js.map +1 -1
  69. package/dist/shared/worker-shutdown.d.ts +67 -0
  70. package/dist/shared/worker-shutdown.js +79 -0
  71. package/dist/shared/worker-shutdown.js.map +1 -0
  72. package/dist/shared/workspace/types.d.ts +3 -2
  73. package/dist/workflow-engine/loader.js +38 -1
  74. package/dist/workflow-engine/loader.js.map +1 -1
  75. package/dist/workflow-engine/tasks/human-input.js +23 -2
  76. package/dist/workflow-engine/tasks/human-input.js.map +1 -1
  77. package/package.json +15 -11
  78. package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +50 -0
  79. package/src/activities/execute-cursor/__tests__/cursor-mcp-config.test.ts +111 -0
  80. package/src/activities/execute-cursor/__tests__/skill-resolver.test.ts +3 -94
  81. package/src/activities/execute-cursor/cursor-mcp-config.ts +107 -0
  82. package/src/activities/execute-cursor/index.ts +63 -35
  83. package/src/activities/execute-cursor/prompt-builder.ts +29 -2
  84. package/src/activities/execute-cursor/session-lifecycle.ts +1 -1
  85. package/src/activities/execute-cursor/skill-resolver.ts +10 -137
  86. package/src/activities/execute-deep-agent/__tests__/approval-file-change.test.ts +15 -0
  87. package/src/activities/execute-deep-agent/__tests__/cas-capture-backend.test.ts +75 -0
  88. package/src/activities/execute-deep-agent/__tests__/hitl-reject.test.ts +1 -0
  89. package/src/activities/execute-deep-agent/__tests__/hitl-resume-approve-all.test.ts +1 -0
  90. package/src/activities/execute-deep-agent/__tests__/hitl-resume-history.test.ts +1 -0
  91. package/src/activities/execute-deep-agent/__tests__/index.test.ts +1 -0
  92. package/src/activities/execute-deep-agent/__tests__/plan-mode-path-normalization.test.ts +42 -29
  93. package/src/activities/execute-deep-agent/__tests__/prompt-builder.test.ts +52 -0
  94. package/src/activities/execute-deep-agent/__tests__/sequential-gate-resume.test.ts +1 -0
  95. package/src/activities/execute-deep-agent/__tests__/subagent-plan-mode-permissions.test.ts +11 -8
  96. package/src/activities/execute-deep-agent/approval-file-change.ts +11 -1
  97. package/src/activities/execute-deep-agent/cas-capture-backend.ts +20 -3
  98. package/src/activities/execute-deep-agent/index.ts +57 -5
  99. package/src/activities/execute-deep-agent/prompt-builder.ts +22 -0
  100. package/src/activities/execute-deep-agent/setup.ts +21 -19
  101. package/src/activities/execute-deep-agent/subagent-transformer.ts +19 -12
  102. package/src/activities/execute-deep-agent/subagent-wiring.ts +3 -3
  103. package/src/index.ts +1 -1
  104. package/src/middleware/__tests__/path-normalization.test.ts +32 -32
  105. package/src/middleware/path-normalization.ts +78 -90
  106. package/src/middleware/types.ts +3 -3
  107. package/src/runner-manager.ts +26 -16
  108. package/src/runner.ts +14 -0
  109. package/src/shared/__tests__/artifact-storage.test.ts +28 -0
  110. package/src/shared/__tests__/bedrock-seam.test.ts +5 -5
  111. package/src/shared/__tests__/declared-preferences.test.ts +109 -0
  112. package/src/shared/__tests__/foundry-seam.test.ts +5 -5
  113. package/src/shared/__tests__/plan-mode-permissions.test.ts +15 -44
  114. package/src/shared/__tests__/skill-mount.test.ts +238 -0
  115. package/src/shared/__tests__/skill-writer.test.ts +151 -128
  116. package/src/shared/__tests__/vertex-adapter.test.ts +30 -3
  117. package/src/shared/__tests__/vertex-seam.test.ts +5 -5
  118. package/src/shared/__tests__/worker-shutdown.test.ts +101 -0
  119. package/src/shared/args-preview.ts +17 -3
  120. package/src/shared/artifact-storage.ts +20 -1
  121. package/src/shared/declared-preferences.ts +84 -0
  122. package/src/shared/mcp-enabled-tools.ts +2 -3
  123. package/src/shared/mcp-resolver.ts +20 -20
  124. package/src/shared/placeholder-resolver.ts +9 -2
  125. package/src/shared/plan-mode-permissions.ts +27 -58
  126. package/src/shared/skill-mount.ts +179 -0
  127. package/src/shared/skill-writer.ts +96 -130
  128. package/src/shared/worker-shutdown.ts +99 -0
  129. package/src/shared/workspace/types.ts +3 -2
  130. package/src/workflow-engine/__tests__/loader.test.ts +51 -0
  131. package/src/workflow-engine/__tests__/tasks/human-input.test.ts +117 -0
  132. package/src/workflow-engine/loader.ts +46 -1
  133. package/src/workflow-engine/tasks/human-input.ts +33 -5
  134. package/dist/activities/execute-cursor/connect-backfill.d.ts +0 -19
  135. package/dist/activities/execute-cursor/connect-backfill.js +0 -27
  136. package/dist/activities/execute-cursor/connect-backfill.js.map +0 -1
  137. package/dist/activities/execute-cursor/mcp-resolver.d.ts +0 -120
  138. package/dist/activities/execute-cursor/mcp-resolver.js +0 -194
  139. package/dist/activities/execute-cursor/mcp-resolver.js.map +0 -1
  140. package/dist/activities/execute-cursor/placeholder-resolver.d.ts +0 -34
  141. package/dist/activities/execute-cursor/placeholder-resolver.js +0 -82
  142. package/dist/activities/execute-cursor/placeholder-resolver.js.map +0 -1
  143. package/src/activities/execute-cursor/__tests__/mcp-resolver.test.ts +0 -125
  144. package/src/activities/execute-cursor/connect-backfill.ts +0 -52
  145. package/src/activities/execute-cursor/mcp-resolver.ts +0 -324
  146. package/src/activities/execute-cursor/placeholder-resolver.ts +0 -109
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Skill writing and prompt generation for the deep-agent execution path.
2
+ * Skill mounting and prompt generation for the deep-agent execution path.
3
3
  *
4
4
  * Follows the Agent Skills specification progressive disclosure model:
5
5
  *
@@ -10,19 +10,33 @@
10
10
  * 3. Resources (on demand) — scripts, references, and assets are
11
11
  * loaded by the agent only when required.
12
12
  *
13
- * Skills live under `.stigmer/skills/{name}/` relative to the workspace root.
14
- * Returned paths are workspace-relative so that the agent's sandbox backend
15
- * resolves them correctly regardless of mount strategy.
13
+ * Skills physically live in the session's platform directory
14
+ * (`{platformDir}/skills/{name}/` the SAME location the Cursor harness
15
+ * mounts into), and the agent sees them as `.stigmer/skills/{name}/`
16
+ * through the per-turn workspace symlink (see workspace/stigmer-link.ts).
17
+ * Returned paths are the agent-visible `.stigmer/…` form.
18
+ *
19
+ * Mounts are cached by the skill's content-addressed version hash via the
20
+ * shared skill-mount mechanics (issue #337, mirroring the Cursor harness's
21
+ * #672 fix): metadata is fetched every execution — a pushed skill update
22
+ * still lands on the very next message — but an unchanged skill skips the
23
+ * artifact download and rewrite entirely.
16
24
  */
17
25
 
18
- import type { WorkspaceBackend } from "./workspace/types.js";
26
+ import { join } from "node:path";
19
27
  import type { Skill } from "@stigmer/protos/ai/stigmer/agentic/skill/v1/api_pb";
20
28
  import type { ApiResourceReference } from "@stigmer/protos/ai/stigmer/commons/apiresource/io_pb";
21
29
  import type { StigmerClient } from "../client/stigmer-client.js";
22
- import { extractZipFileEntries } from "./zip-extract.js";
30
+ import {
31
+ SKILLS_SUBDIR,
32
+ mountIsFresh,
33
+ downloadArtifact,
34
+ writeSkillMount,
35
+ } from "./skill-mount.js";
36
+ import { STIGMER_LOCAL_STATE_DIR } from "./workspace/stigmer-link.js";
23
37
 
24
- const SKILLS_RELATIVE_BASE = ".stigmer/skills";
25
- const SCRIPT_EXTENSIONS = new Set([".sh", ".py", ".js", ".ts", ".rb", ".pl"]);
38
+ /** Agent-visible base of the skills tree (via the workspace `.stigmer` symlink). */
39
+ const SKILLS_RELATIVE_BASE = `${STIGMER_LOCAL_STATE_DIR}/${SKILLS_SUBDIR}`;
26
40
 
27
41
  // ─── Types ───────────────────────────────────────────────────────────────
28
42
 
@@ -81,114 +95,100 @@ export async function fetchSkillsByRefs(
81
95
  return results;
82
96
  }
83
97
 
84
- // ─── Write ───────────────────────────────────────────────────────────────
98
+ // ─── Mount ───────────────────────────────────────────────────────────────
85
99
 
86
100
  /**
87
- * Write skill artifacts to the workspace. Returns a map of
88
- * skill-id -> workspace-relative directory path.
101
+ * Mount skills into the session's platform directory, downloading each
102
+ * skill's artifact only on a version-hash cache miss (see skill-mount.ts
103
+ * for the marker mechanics). Returns a map of skill-id -> agent-visible
104
+ * directory path.
105
+ *
106
+ * The returned map is a NAMING function, not a success record: every skill
107
+ * gets its path entry so prompt generation stays total, while mount
108
+ * failures degrade that one skill (loud log, no throw) — one broken skill
109
+ * must never kill the run.
89
110
  *
90
- * For each skill:
91
- * - If a ZIP artifact is available, extract it to .stigmer/skills/{name}/
92
- * - Otherwise, write just the SKILL.md from the spec
93
- * - Make scripts executable
111
+ * Skills mount in parallel (each owns an independent directory), EXCEPT
112
+ * when two skills resolve to the same directory name (same `spec.name`
113
+ * from different orgs): concurrent remove-and-rewrite passes on one
114
+ * directory would corrupt the mount, so only the first claimant mounts and
115
+ * the collision is logged loudly. (The sequential code this replaced
116
+ * silently let the last writer win — no better, just quieter.)
94
117
  */
95
- export async function writeSkills(
118
+ export async function mountSkills(
119
+ client: StigmerClient,
96
120
  skills: readonly Skill[],
97
- workspaceBackend: WorkspaceBackend,
98
- artifacts: ReadonlyMap<string, Uint8Array>,
121
+ platformDir: string,
99
122
  ): Promise<SkillPathMap> {
100
123
  const paths = new Map<string, string>();
124
+ const claims = new Map<string, Skill>();
101
125
 
102
126
  for (const skill of skills) {
103
127
  const name = skill.spec?.name || skill.metadata?.slug || "unknown";
104
128
  const skillId = skill.metadata?.id ?? name;
105
- const relativeDir = `${SKILLS_RELATIVE_BASE}/${name}`;
106
- paths.set(skillId, relativeDir);
129
+ paths.set(skillId, `${SKILLS_RELATIVE_BASE}/${name}`);
107
130
 
108
- const artifactBytes = artifacts.get(skillId);
109
- if (skill.spec?.skillMd) {
110
- const skillMdPath = `${relativeDir}/SKILL.md`;
111
- await workspaceBackend.writeFile(skillMdPath, skill.spec.skillMd);
112
- if (artifactBytes && artifactBytes.length > 0) {
113
- await extractZipToWorkspaceExcluding(artifactBytes, "SKILL.md", relativeDir, workspaceBackend);
114
- }
115
- } else if (artifactBytes && artifactBytes.length > 0) {
116
- await extractZipToWorkspace(artifactBytes, relativeDir, workspaceBackend);
131
+ // Impossible through the push pipeline (push.go hard-fails a ZIP
132
+ // without an extractable SKILL.md and always populates spec.skill_md),
133
+ // so an empty skillMd means a broken resource — skip, same as Cursor.
134
+ if (!skill.spec?.skillMd) {
135
+ console.warn(
136
+ `[skill-writer] skill ${name} (${skillId}) has no skillMd content — skipping mount`,
137
+ );
138
+ continue;
117
139
  }
118
140
 
119
- await makeScriptsExecutable(relativeDir, workspaceBackend);
120
- }
121
-
122
- return { paths };
123
- }
124
-
125
- /**
126
- * Compute skill paths without writing anything (for resume integrity checks).
127
- */
128
- export function computeSkillPaths(skills: readonly Skill[]): Map<string, string> {
129
- const paths = new Map<string, string>();
130
- for (const skill of skills) {
131
- const name = skill.spec?.name || skill.metadata?.slug || "unknown";
132
- const skillId = skill.metadata?.id ?? name;
133
- paths.set(skillId, `${SKILLS_RELATIVE_BASE}/${name}`);
141
+ const prev = claims.get(name);
142
+ if (prev) {
143
+ console.warn(
144
+ `[skill-writer] mount directory collision on '${name}': ` +
145
+ `${prev.metadata?.org}/${prev.metadata?.slug} already claims it, ` +
146
+ `skipping ${skill.metadata?.org}/${skill.metadata?.slug}`,
147
+ );
148
+ continue;
149
+ }
150
+ claims.set(name, skill);
134
151
  }
135
- return paths;
136
- }
137
-
138
- /**
139
- * Check workspace integrity for resume fast-path.
140
- * Returns true if the sentinel SKILL.md file exists for the first skill.
141
- */
142
- export async function checkSkillIntegrity(
143
- skills: readonly Skill[],
144
- workspaceBackend: WorkspaceBackend,
145
- ): Promise<boolean> {
146
- if (skills.length === 0) return true;
147
-
148
- const paths = computeSkillPaths(skills);
149
- const firstPath = paths.values().next().value;
150
- if (!firstPath) return true;
151
152
 
152
- const sentinel = `${firstPath}/SKILL.md`;
153
- return workspaceBackend.exists(sentinel);
154
- }
153
+ await Promise.all([...claims.entries()].map(async ([name, skill]) => {
154
+ const skillDir = join(platformDir, SKILLS_SUBDIR, name);
155
+ try {
156
+ const versionHash = skill.status?.versionHash ?? "";
157
+ const wantsArtifact = Boolean(skill.status?.artifactStorageKey);
155
158
 
156
- // ─── ZIP extraction ──────────────────────────────────────────────────────
159
+ if (versionHash !== "" && (await mountIsFresh(skillDir, versionHash, wantsArtifact))) {
160
+ console.log(
161
+ `[skill-writer] mount cache hit: ${name} (version ${versionHash.slice(0, 12)}) — skipping artifact transfer`,
162
+ );
163
+ return;
164
+ }
157
165
 
158
- async function extractZipToWorkspace(
159
- zipBytes: Uint8Array,
160
- targetDir: string,
161
- backend: WorkspaceBackend,
162
- ): Promise<void> {
163
- const entries = await extractZipFileEntries(zipBytes);
164
- for (const entry of entries) {
165
- await backend.writeFileBuffer(`${targetDir}/${entry.path}`, Buffer.from(entry.content));
166
- }
167
- }
166
+ let artifactBytes: Uint8Array | undefined;
167
+ if (wantsArtifact) {
168
+ try {
169
+ artifactBytes = await downloadArtifact(client, skill.status!.artifactStorageKey);
170
+ } catch (err) {
171
+ // Deliberate degradation, but LOUD (#675): the run still gets
172
+ // SKILL.md, and the marker records artifactMounted=false so the
173
+ // next execution retries the download.
174
+ console.error(
175
+ `[skill-writer] artifact download FAILED for ${name} ` +
176
+ `(key=${skill.status!.artifactStorageKey}) — mounting SKILL.md WITHOUT the skill's ` +
177
+ `supporting files (scripts/references will be missing): ${err instanceof Error ? err.message : err}`,
178
+ );
179
+ }
180
+ }
168
181
 
169
- async function extractZipToWorkspaceExcluding(
170
- zipBytes: Uint8Array,
171
- excludeName: string,
172
- targetDir: string,
173
- backend: WorkspaceBackend,
174
- ): Promise<void> {
175
- const entries = await extractZipFileEntries(zipBytes, { exclude: [excludeName] });
176
- for (const entry of entries) {
177
- await backend.writeFileBuffer(`${targetDir}/${entry.path}`, Buffer.from(entry.content));
178
- }
179
- }
182
+ await writeSkillMount(skill, skillDir, artifactBytes);
183
+ console.log(`[skill-writer] wrote skill mount: ${name}`);
184
+ } catch (err) {
185
+ console.warn(
186
+ `[skill-writer] failed to mount skill ${name}: ${err instanceof Error ? err.message : err}`,
187
+ );
188
+ }
189
+ }));
180
190
 
181
- async function makeScriptsExecutable(
182
- relativeDir: string,
183
- backend: WorkspaceBackend,
184
- ): Promise<void> {
185
- const extensions = [...SCRIPT_EXTENSIONS].map(ext => `-name '*${ext}'`).join(" -o ");
186
- const cmd = `find ${relativeDir} -type f \\( ${extensions} \\) -exec chmod +x {} \\; 2>/dev/null || true`;
187
- try {
188
- await backend.execute(cmd);
189
- } catch {
190
- // Non-fatal: scripts may not be executable in all environments
191
- }
191
+ return { paths };
192
192
  }
193
193
 
194
194
  // ─── Prompt generation ───────────────────────────────────────────────────
@@ -264,37 +264,3 @@ export function generateAlsoAvailableSection(
264
264
  "read its SKILL.md at `.stigmer/skills/<name>/SKILL.md` to activate it.\n"
265
265
  );
266
266
  }
267
-
268
- // ─── Artifact fetching ───────────────────────────────────────────────────
269
-
270
- /**
271
- * Download skill artifacts for all skills that have a storage key.
272
- * Returns a map from skill ID to artifact bytes.
273
- */
274
- export async function fetchSkillArtifacts(
275
- client: StigmerClient,
276
- skills: readonly Skill[],
277
- ): Promise<Map<string, Uint8Array>> {
278
- const artifacts = new Map<string, Uint8Array>();
279
-
280
- const fetches = skills
281
- .filter(s => s.status?.artifactStorageKey)
282
- .map(async (skill) => {
283
- const key = skill.status!.artifactStorageKey;
284
- const skillId = skill.metadata?.id ?? skill.spec?.name ?? "unknown";
285
- try {
286
- const response = await client.getSkillArtifact(key);
287
- if (response.artifact && response.artifact.length > 0) {
288
- artifacts.set(skillId, response.artifact);
289
- }
290
- } catch (err) {
291
- console.warn(
292
- `[skill-writer] Failed to download artifact for ${skill.spec?.name}: ` +
293
- `${err instanceof Error ? err.message : String(err)}. Falling back to SKILL.md only.`,
294
- );
295
- }
296
- });
297
-
298
- await Promise.all(fetches);
299
- return artifacts;
300
- }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Per-task-queue worker-shutdown signals — the classification channel that
3
+ * lets an in-flight activity distinguish "my worker is shutting down" from
4
+ * "the orchestrator cancelled me" (a user pause).
5
+ *
6
+ * The signal carries NO lifecycle authority: aborting it never stops a
7
+ * worker or an activity (Temporal's own drain does that). It exists purely
8
+ * so the activity's cancellation handling can classify the interruption
9
+ * honestly — a shutdown is not a pause, and must surface as the
10
+ * worker-shutdown failure shape the control planes recognize (issue #776).
11
+ *
12
+ * Ownership contract:
13
+ * - whoever creates a Worker registers a signal for its queue BEFORE the
14
+ * worker starts polling;
15
+ * - whoever initiates a full shutdown (SIGTERM handler, desktop quit)
16
+ * aborts the signal BEFORE calling worker.shutdown(), so activities
17
+ * cancelled by the drain observe it already aborted;
18
+ * - a graceful single-worker teardown (view close, deferred teardown in
19
+ * the runner-manager) must NOT abort — those paths only run once no
20
+ * activity is in flight, and aborting earlier is exactly the regression
21
+ * that killed running activities on a view close.
22
+ *
23
+ * Module-level (not per-manager) because activities resolve their signal by
24
+ * task-queue name via {@link getShutdownSignalForQueue} without a handle to
25
+ * the runner/manager instance that created their worker.
26
+ */
27
+
28
+ const registry = new Map<string, AbortController>();
29
+
30
+ /**
31
+ * Register a fresh shutdown signal for a task queue, replacing any previous
32
+ * registration (a re-created worker on a reused queue must not observe the
33
+ * old worker's aborted signal). Returns the controller so the worker's owner
34
+ * can abort it at shutdown.
35
+ */
36
+ export function registerWorkerShutdownSignal(taskQueue: string): AbortController {
37
+ const controller = new AbortController();
38
+ registry.set(taskQueue, controller);
39
+ return controller;
40
+ }
41
+
42
+ /** The signal an activity on `taskQueue` should observe, if one is registered. */
43
+ export function getShutdownSignalForQueue(taskQueue: string): AbortSignal | undefined {
44
+ return registry.get(taskQueue)?.signal;
45
+ }
46
+
47
+ /**
48
+ * Abort the queue's signal, marking any in-flight activity's imminent
49
+ * cancellation as a worker shutdown. Idempotent; no-op for an unknown queue.
50
+ */
51
+ export function signalWorkerShutdown(taskQueue: string): void {
52
+ registry.get(taskQueue)?.abort();
53
+ }
54
+
55
+ /** Drop the registration once the queue's worker is fully torn down. */
56
+ export function unregisterWorkerShutdownSignal(taskQueue: string): void {
57
+ registry.delete(taskQueue);
58
+ }
59
+
60
+ /** The evidence a turn's post-stream classification weighs (see below). */
61
+ export interface TurnInterruptionEvidence {
62
+ /** The periodic heartbeat threw CancelledFailure with no shutdown signal. */
63
+ heartbeatCancelled: boolean;
64
+ /** The periodic heartbeat threw CancelledFailure with the signal aborted. */
65
+ heartbeatWorkerShutdown: boolean;
66
+ /** Temporal delivered cancellation to the activity. */
67
+ cancellationSignalAborted: boolean;
68
+ /** This queue's worker-shutdown signal is aborted. */
69
+ shutdownSignalAborted: boolean;
70
+ }
71
+
72
+ /**
73
+ * Classify how (whether) a turn's stream was interrupted, from the evidence
74
+ * available after the stream ends. Pure so the decision table is directly
75
+ * testable — the activities feed it their heartbeat flags and signals.
76
+ *
77
+ * The load-bearing rule is the grace-window guard (#776): an aborted
78
+ * shutdown signal ALONE is "none", because a run that completes normally
79
+ * inside the drain grace window reaches the classification with the signal
80
+ * already aborted and nothing actually interrupted. Shutdown requires
81
+ * interruption evidence (a heartbeat CancelledFailure or a delivered
82
+ * cancellation) alongside the signal; a heartbeat cancellation with no
83
+ * shutdown signal is the orchestrator's pause.
84
+ */
85
+ export function classifyTurnInterruption(
86
+ evidence: TurnInterruptionEvidence,
87
+ ): "worker-shutdown" | "pause" | "none" {
88
+ const interrupted =
89
+ evidence.heartbeatCancelled ||
90
+ evidence.heartbeatWorkerShutdown ||
91
+ evidence.cancellationSignalAborted;
92
+ if (!interrupted) {
93
+ return "none";
94
+ }
95
+ if (evidence.heartbeatWorkerShutdown || evidence.shutdownSignalAborted) {
96
+ return "worker-shutdown";
97
+ }
98
+ return evidence.heartbeatCancelled ? "pause" : "none";
99
+ }
@@ -49,8 +49,9 @@ export class WorkspaceProvisionError extends Error {
49
49
  /**
50
50
  * Abstraction for executing commands inside a workspace.
51
51
  *
52
- * In local mode, commands run via child_process. In cloud mode (Phase 3),
53
- * commands route through the Daytona sandbox proxy.
52
+ * Commands run via child_process in both modes: in cloud mode the runner
53
+ * itself lives inside the sandbox, so "local" execution is already
54
+ * sandbox-scoped there.
54
55
  *
55
56
  * When `platformDir` is set, paths under `.stigmer/` are transparently
56
57
  * routed to the platform directory instead of the workspace root, keeping
@@ -1528,6 +1528,57 @@ do:
1528
1528
  }
1529
1529
  });
1530
1530
 
1531
+ // The server converter persists on_timeout as the proto enum NAME
1532
+ // (HumanInputTimeoutPolicy.String()), so every applied workflow carries
1533
+ // that form in its validated YAML. stigmer/stigmer#779: the loader used
1534
+ // to cast it unvalidated, and the orchestrator's switch silently treated
1535
+ // the unrecognized string as fail.
1536
+ const humanInputYamlWithOnTimeout = (onTimeout: string) => `
1537
+ document:
1538
+ dsl: '1.0.0'
1539
+ name: test
1540
+ do:
1541
+ - timedApproval:
1542
+ call: human_input
1543
+ with:
1544
+ prompt: "Approve within time limit"
1545
+ timeout: 3600
1546
+ on_timeout: ${onTimeout}
1547
+ `;
1548
+
1549
+ it("call: human_input normalizes proto enum-name on_timeout values to the internal policy words", () => {
1550
+ const cases = [
1551
+ ["HUMAN_INPUT_TIMEOUT_FAIL", "fail"],
1552
+ ["HUMAN_INPUT_TIMEOUT_APPROVE", "approve"],
1553
+ ["HUMAN_INPUT_TIMEOUT_DENY", "deny"],
1554
+ ] as const;
1555
+ for (const [wireForm, internalForm] of cases) {
1556
+ const model = loadWorkflowFromYaml(humanInputYamlWithOnTimeout(wireForm));
1557
+ const task = model.do[0].task;
1558
+ expect(task.kind).toBe("human_input");
1559
+ if (task.kind === "human_input") {
1560
+ expect(task.humanInput.onTimeout).toBe(internalForm);
1561
+ }
1562
+ }
1563
+ });
1564
+
1565
+ it("call: human_input rejects the not-implemented escalate policy at load time", () => {
1566
+ for (const form of ["HUMAN_INPUT_TIMEOUT_ESCALATE", "escalate"]) {
1567
+ expect(() => loadWorkflowFromYaml(humanInputYamlWithOnTimeout(form)))
1568
+ .toThrow(/timedApproval.*escalate.*not implemented/);
1569
+ }
1570
+ });
1571
+
1572
+ it("call: human_input rejects unknown on_timeout values instead of silently failing at timeout", () => {
1573
+ expect(() => loadWorkflowFromYaml(humanInputYamlWithOnTimeout("sometimes")))
1574
+ .toThrow(/timedApproval.*unknown on_timeout value 'sometimes'.*fail, approve, deny/);
1575
+ });
1576
+
1577
+ it("call: human_input rejects non-string on_timeout values", () => {
1578
+ expect(() => loadWorkflowFromYaml(humanInputYamlWithOnTimeout("42")))
1579
+ .toThrow(/timedApproval.*on_timeout/);
1580
+ });
1581
+
1531
1582
  it("call: human_input with payload and ui_hint", () => {
1532
1583
  const yaml = `
1533
1584
  document:
@@ -246,6 +246,123 @@ describe("executeHumanInputTask", () => {
246
246
  });
247
247
  });
248
248
 
249
+ // HumanInputTaskConfig.outcomes contract (human_input.proto): with custom
250
+ // outcomes, timeout auto-approve resolves to the FIRST outcome and timeout
251
+ // auto-deny to the LAST — downstream `then` routing and outcome switches
252
+ // must see declared outcome names, never the internal approve/deny words a
253
+ // reviewer was never offered. stigmer/stigmer#779 made this path reachable
254
+ // for the first time.
255
+ describe("timeout outcome mapping with custom outcomes", () => {
256
+ const outcomes = [
257
+ { name: "proceed", label: "Proceed", then: "deployStep" },
258
+ { name: "needs_revision", label: "Needs revision", then: "gatherMore" },
259
+ { name: "reject", label: "Reject" },
260
+ ];
261
+
262
+ it("maps timeout auto-approve to the FIRST outcome and routes its then", async () => {
263
+ const awaitFn: AwaitHumanInputFn = async () => ({
264
+ outcome: "approve",
265
+ auto_resolved: true,
266
+ reason: "timeout",
267
+ });
268
+
269
+ const taskDef: HumanInputTaskDef = {
270
+ kind: "human_input",
271
+ humanInput: { prompt: "Review", timeout: 5, onTimeout: "approve", outcomes },
272
+ };
273
+
274
+ const state = createState();
275
+ const result = await executeHumanInputTask(taskDef, "gate", state, makeCtx(awaitFn));
276
+
277
+ expect(result).toEqual({
278
+ outcome: "proceed",
279
+ auto_resolved: true,
280
+ reason: "timeout",
281
+ __flow_directive__: "deployStep",
282
+ });
283
+ expect(state.data.gate).toEqual({
284
+ outcome: "proceed",
285
+ auto_resolved: true,
286
+ reason: "timeout",
287
+ });
288
+ });
289
+
290
+ it("maps timeout auto-deny to the LAST outcome", async () => {
291
+ const awaitFn: AwaitHumanInputFn = async () => ({
292
+ outcome: "deny",
293
+ auto_resolved: true,
294
+ reason: "timeout",
295
+ });
296
+
297
+ const taskDef: HumanInputTaskDef = {
298
+ kind: "human_input",
299
+ humanInput: { prompt: "Review", timeout: 5, onTimeout: "deny", outcomes },
300
+ };
301
+
302
+ const result = await executeHumanInputTask(taskDef, "gate", createState(), makeCtx(awaitFn));
303
+
304
+ // "reject" is the last outcome and declares no `then` — no directive.
305
+ expect(result).toEqual({ outcome: "reject", auto_resolved: true, reason: "timeout" });
306
+ });
307
+
308
+ it("reports the mapped outcome on the approval_resolved event", async () => {
309
+ const emitted: WorkflowEventDescriptor[][] = [];
310
+ const emitFn: EmitEventsFn = async (events) => { emitted.push(events); };
311
+ const awaitFn: AwaitHumanInputFn = async () => ({
312
+ outcome: "approve",
313
+ auto_resolved: true,
314
+ reason: "timeout",
315
+ });
316
+
317
+ const taskDef: HumanInputTaskDef = {
318
+ kind: "human_input",
319
+ humanInput: { prompt: "Review", timeout: 5, onTimeout: "approve", outcomes },
320
+ };
321
+
322
+ await executeHumanInputTask(taskDef, "gate", createState(), makeCtx(awaitFn, emitFn));
323
+
324
+ const resolved = emitted.flat().find((e) => e.type === "approval_resolved");
325
+ expect(resolved).toMatchObject({ outcome: "proceed", autoResolved: true });
326
+ });
327
+
328
+ it("leaves reviewer-selected outcomes untouched", async () => {
329
+ const awaitFn: AwaitHumanInputFn = async () => ({
330
+ outcome: "needs_revision",
331
+ reviewer: "alice",
332
+ });
333
+
334
+ const taskDef: HumanInputTaskDef = {
335
+ kind: "human_input",
336
+ humanInput: { prompt: "Review", timeout: 5, onTimeout: "approve", outcomes },
337
+ };
338
+
339
+ const result = await executeHumanInputTask(taskDef, "gate", createState(), makeCtx(awaitFn));
340
+
341
+ expect(result).toEqual({
342
+ outcome: "needs_revision",
343
+ reviewer: "alice",
344
+ __flow_directive__: "gatherMore",
345
+ });
346
+ });
347
+
348
+ it("keeps plain approve/deny for binary gates without custom outcomes", async () => {
349
+ const awaitFn: AwaitHumanInputFn = async () => ({
350
+ outcome: "approve",
351
+ auto_resolved: true,
352
+ reason: "timeout",
353
+ });
354
+
355
+ const taskDef: HumanInputTaskDef = {
356
+ kind: "human_input",
357
+ humanInput: { prompt: "Review", timeout: 5, onTimeout: "approve" },
358
+ };
359
+
360
+ const result = await executeHumanInputTask(taskDef, "gate", createState(), makeCtx(awaitFn));
361
+
362
+ expect(result).toEqual({ outcome: "approve", auto_resolved: true, reason: "timeout" });
363
+ });
364
+ });
365
+
249
366
  describe("event emission", () => {
250
367
  it("emits approval_requested before blocking with correct fields", async () => {
251
368
  const emitted: WorkflowEventDescriptor[][] = [];
@@ -608,6 +608,51 @@ function parseServiceTier(raw: unknown): string | undefined {
608
608
  return canonical;
609
609
  }
610
610
 
611
+ /**
612
+ * Maps on_timeout values to the runner's internal policy words, mirroring
613
+ * SERVICE_TIER_SHORTHANDS. The persisted CNCF YAML carries the proto enum
614
+ * NAMES (the server converter emits `HumanInputTimeoutPolicy.String()`),
615
+ * while hand-written fixtures use the lowercase words — both are accepted.
616
+ * Unknown values are authoring errors: a timeout policy must never silently
617
+ * fall back to fail (stigmer/stigmer#779 — the unvalidated cast let every
618
+ * enum-name policy reach the orchestrator unrecognized, so gates configured
619
+ * to auto-approve/deny failed at their first real timeout instead).
620
+ */
621
+ const ON_TIMEOUT_VOCABULARY: Record<string, "fail" | "approve" | "deny"> = {
622
+ fail: "fail",
623
+ approve: "approve",
624
+ deny: "deny",
625
+ HUMAN_INPUT_TIMEOUT_FAIL: "fail",
626
+ HUMAN_INPUT_TIMEOUT_APPROVE: "approve",
627
+ HUMAN_INPUT_TIMEOUT_DENY: "deny",
628
+ };
629
+
630
+ function parseOnTimeout(
631
+ taskName: string,
632
+ raw: unknown,
633
+ ): "fail" | "approve" | "deny" | undefined {
634
+ if (raw === undefined || raw === null) return undefined;
635
+ if (typeof raw !== "string") {
636
+ throw new Error(`human_input task '${taskName}': 'on_timeout' must be a string`);
637
+ }
638
+ // The proto declares HUMAN_INPUT_TIMEOUT_ESCALATE but no runtime exists
639
+ // for it yet; refuse at load rather than misbehave at the gate's timeout.
640
+ if (raw === "HUMAN_INPUT_TIMEOUT_ESCALATE" || raw === "escalate") {
641
+ throw new Error(
642
+ `human_input task '${taskName}': on_timeout policy 'escalate' is not implemented — ` +
643
+ `use fail, approve, or deny (custom outcomes with 'then' cover reviewer-driven branching)`,
644
+ );
645
+ }
646
+ const policy = ON_TIMEOUT_VOCABULARY[raw];
647
+ if (!policy) {
648
+ throw new Error(
649
+ `human_input task '${taskName}': unknown on_timeout value '${raw}' ` +
650
+ `(expected: fail, approve, deny, or a HUMAN_INPUT_TIMEOUT_* enum name)`,
651
+ );
652
+ }
653
+ return policy;
654
+ }
655
+
611
656
  function parseHumanInputConfig(taskName: string, raw: unknown): import("./types.js").HumanInputConfig {
612
657
  if (!raw || typeof raw !== "object") {
613
658
  throw new Error(`human_input task '${taskName}' requires a 'with' configuration block`);
@@ -630,7 +675,7 @@ function parseHumanInputConfig(taskName: string, raw: unknown): import("./types.
630
675
  ? obj.approvers.filter((a: unknown) => typeof a === "string") as string[]
631
676
  : undefined,
632
677
  timeout: typeof obj.timeout === "number" ? obj.timeout : undefined,
633
- onTimeout: (obj.on_timeout as "fail" | "approve" | "deny") ?? undefined,
678
+ onTimeout: parseOnTimeout(taskName, obj.on_timeout),
634
679
  // Any JSON shape is a valid payload (expression string, object, array),
635
680
  // so only null/undefined mean "no payload" here.
636
681
  payload: obj.payload ?? undefined,