@opengsd/gsd-pi 1.6.0 → 1.7.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 (92) hide show
  1. package/README.md +6 -10
  2. package/dist/resources/.managed-resources-content-hash +1 -1
  3. package/dist/resources/extensions/gsd/auto-post-unit.js +19 -3
  4. package/dist/resources/extensions/gsd/auto-prompts.js +20 -0
  5. package/dist/resources/extensions/gsd/auto.js +12 -0
  6. package/dist/resources/extensions/gsd/browser-daemon-auto-prep.js +68 -2
  7. package/dist/resources/extensions/gsd/reopen-reason.js +98 -0
  8. package/dist/resources/extensions/gsd/session-status-io.js +31 -0
  9. package/dist/resources/extensions/gsd/tools/reopen-task.js +15 -0
  10. package/dist/resources/extensions/shared/gsd-browser-cli.js +22 -5
  11. package/dist/tsconfig.extensions.tsbuildinfo +1 -1
  12. package/dist/web/standalone/.next/BUILD_ID +1 -1
  13. package/dist/web/standalone/.next/app-path-routes-manifest.json +11 -11
  14. package/dist/web/standalone/.next/build-manifest.json +2 -2
  15. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  16. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  17. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  18. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  19. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  20. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  21. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  22. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  23. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  24. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  25. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  26. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  27. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  28. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  29. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  30. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  31. package/dist/web/standalone/.next/server/app/index.html +1 -1
  32. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  33. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  34. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  35. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  36. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  37. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  38. package/dist/web/standalone/.next/server/app-paths-manifest.json +11 -11
  39. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  40. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  41. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  42. package/dist/web/standalone/node_modules/@gsd/native/package.json +1 -1
  43. package/dist/web/standalone/node_modules/node-pty/build/Makefile +1 -1
  44. package/integrations/hermes/open_gsd_hermes/gsd_client.py +1 -1
  45. package/integrations/hermes/pyproject.toml +1 -1
  46. package/package.json +6 -6
  47. package/packages/cloud-mcp-gateway/package.json +2 -2
  48. package/packages/contracts/package.json +1 -1
  49. package/packages/daemon/dist/cloud-cli.d.ts +1 -1
  50. package/packages/daemon/dist/cloud-cli.d.ts.map +1 -1
  51. package/packages/daemon/dist/cloud-cli.js +42 -2
  52. package/packages/daemon/dist/cloud-cli.js.map +1 -1
  53. package/packages/daemon/dist/cloud-config.d.ts +1 -0
  54. package/packages/daemon/dist/cloud-config.d.ts.map +1 -1
  55. package/packages/daemon/dist/cloud-config.js +1 -1
  56. package/packages/daemon/dist/cloud-config.js.map +1 -1
  57. package/packages/daemon/dist/device-flow.d.ts +24 -0
  58. package/packages/daemon/dist/device-flow.d.ts.map +1 -0
  59. package/packages/daemon/dist/device-flow.js +128 -0
  60. package/packages/daemon/dist/device-flow.js.map +1 -0
  61. package/packages/daemon/dist/device-flow.test.d.ts +2 -0
  62. package/packages/daemon/dist/device-flow.test.d.ts.map +1 -0
  63. package/packages/daemon/dist/device-flow.test.js +172 -0
  64. package/packages/daemon/dist/device-flow.test.js.map +1 -0
  65. package/packages/daemon/package.json +5 -5
  66. package/packages/gsd-agent-core/package.json +5 -5
  67. package/packages/gsd-agent-modes/package.json +7 -7
  68. package/packages/gsd-cloud/README.md +34 -0
  69. package/packages/gsd-cloud/bin/gsd-cloud.js +27 -0
  70. package/packages/gsd-cloud/package.json +38 -0
  71. package/packages/mcp-server/package.json +4 -4
  72. package/packages/native/package.json +1 -1
  73. package/packages/pi-agent-core/package.json +1 -1
  74. package/packages/pi-ai/package.json +1 -1
  75. package/packages/pi-coding-agent/package.json +7 -7
  76. package/packages/pi-tui/package.json +2 -2
  77. package/packages/rpc-client/package.json +2 -2
  78. package/pkg/package.json +1 -1
  79. package/src/resources/extensions/browser-tools/tests/gsd-browser-launch-config.test.mjs +19 -0
  80. package/src/resources/extensions/gsd/auto-post-unit.ts +22 -3
  81. package/src/resources/extensions/gsd/auto-prompts.ts +20 -0
  82. package/src/resources/extensions/gsd/auto.ts +12 -0
  83. package/src/resources/extensions/gsd/browser-daemon-auto-prep.ts +77 -1
  84. package/src/resources/extensions/gsd/reopen-reason.ts +109 -0
  85. package/src/resources/extensions/gsd/session-status-io.ts +34 -0
  86. package/src/resources/extensions/gsd/tests/browser-daemon-auto-prep.test.ts +26 -0
  87. package/src/resources/extensions/gsd/tests/reopen-reason.test.ts +117 -0
  88. package/src/resources/extensions/gsd/tests/worker-resume-wait.test.ts +73 -0
  89. package/src/resources/extensions/gsd/tools/reopen-task.ts +15 -0
  90. package/src/resources/extensions/shared/gsd-browser-cli.ts +35 -7
  91. /package/dist/web/standalone/.next/static/{6xYMOzxVhsQgryneJ5KRc → enWQFRY-aLTUq3wmxG2Qz}/_buildManifest.js +0 -0
  92. /package/dist/web/standalone/.next/static/{6xYMOzxVhsQgryneJ5KRc → enWQFRY-aLTUq3wmxG2Qz}/_ssgManifest.js +0 -0
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@opengsd/gsd-cloud",
3
+ "version": "1.7.0",
4
+ "description": "Thin CLI wrapper that connects a local GSD runtime to GSD Cloud (cloud.opengsd.net)",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/open-gsd/gsd-pi.git",
9
+ "directory": "packages/gsd-cloud"
10
+ },
11
+ "publishConfig": {
12
+ "access": "public"
13
+ },
14
+ "type": "module",
15
+ "bin": {
16
+ "gsd-cloud": "./bin/gsd-cloud.js"
17
+ },
18
+ "scripts": {
19
+ "build": "node ../../scripts/clean-package-dist.cjs && tsc",
20
+ "test": "pnpm run build && node --test dist/inject-gateway.test.js"
21
+ },
22
+ "dependencies": {
23
+ "@opengsd/daemon": "^1.7.0"
24
+ },
25
+ "devDependencies": {
26
+ "@types/node": "^24.12.0",
27
+ "typescript": "^5.4.0"
28
+ },
29
+ "engines": {
30
+ "node": ">=22.0.0"
31
+ },
32
+ "files": [
33
+ "README.md",
34
+ "bin",
35
+ "dist",
36
+ "!dist/**/*.test.*"
37
+ ]
38
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengsd/mcp-server",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "description": "MCP server exposing GSD orchestration tools for compatible clients",
5
5
  "license": "MIT",
6
6
  "gsd": {
@@ -50,9 +50,9 @@
50
50
  "test": "pnpm run build:test && node --test dist/mcp-server.test.js dist/remote-questions.test.js dist/moonshot-tool-schema.test.js dist/pid-registry.test.js dist/probe-mode.test.js dist/stdio-watchdog.test.js dist/cli-runner.test.js dist/readers/graph.test.js dist/readers/paths.test.js dist/readers/readers.test.js"
51
51
  },
52
52
  "dependencies": {
53
- "@gsd/pi-ai": "^1.6.0",
54
- "@opengsd/contracts": "^1.6.0",
55
- "@opengsd/rpc-client": "^1.6.0",
53
+ "@gsd/pi-ai": "^1.7.0",
54
+ "@opengsd/contracts": "^1.7.0",
55
+ "@opengsd/rpc-client": "^1.7.0",
56
56
  "@modelcontextprotocol/sdk": "^1.27.1",
57
57
  "zod": "^4.0.0"
58
58
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gsd/native",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "description": "Native Rust bindings for GSD — high-performance native modules via N-API",
5
5
  "type": "commonjs",
6
6
  "gsd": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gsd/pi-agent-core",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "description": "General-purpose agent with transport abstraction, state management, and attachment support",
5
5
  "type": "module",
6
6
  "gsd": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gsd/pi-ai",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "description": "Unified LLM API with automatic model discovery and provider configuration",
5
5
  "type": "module",
6
6
  "gsd": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gsd/pi-coding-agent",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "description": "Coding agent CLI (vendored from earendil-works/pi)",
5
5
  "type": "module",
6
6
  "gsd": {
@@ -33,7 +33,7 @@
33
33
  "copy-assets": "node scripts/copy-assets.cjs"
34
34
  },
35
35
  "dependencies": {
36
- "@opengsd/contracts": "^1.6.0",
36
+ "@opengsd/contracts": "^1.7.0",
37
37
  "@mariozechner/jiti": "^2.6.2",
38
38
  "@silvia-odwyer/photon-node": "0.3.4",
39
39
  "chalk": "5.6.2",
@@ -53,11 +53,11 @@
53
53
  "typebox": "1.1.38",
54
54
  "undici": "7.28.0",
55
55
  "yaml": "2.9.0",
56
- "@gsd/agent-core": "^1.6.0",
57
- "@gsd/native": "^1.6.0",
58
- "@gsd/pi-agent-core": "^1.6.0",
59
- "@gsd/pi-ai": "^1.6.0",
60
- "@gsd/pi-tui": "^1.6.0",
56
+ "@gsd/agent-core": "^1.7.0",
57
+ "@gsd/native": "^1.7.0",
58
+ "@gsd/pi-agent-core": "^1.7.0",
59
+ "@gsd/pi-ai": "^1.7.0",
60
+ "@gsd/pi-tui": "^1.7.0",
61
61
  "@sinclair/typebox": "^0.34.41"
62
62
  },
63
63
  "devDependencies": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gsd/pi-tui",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "description": "Terminal UI library (vendored from earendil-works/pi)",
5
5
  "type": "module",
6
6
  "gsd": {
@@ -21,7 +21,7 @@
21
21
  "build": "node ../../scripts/clean-package-dist.cjs && tsc -p tsconfig.json --incremental false"
22
22
  },
23
23
  "dependencies": {
24
- "@gsd/native": "^1.6.0",
24
+ "@gsd/native": "^1.7.0",
25
25
  "get-east-asian-width": "1.6.0",
26
26
  "marked": "15.0.12",
27
27
  "@sinclair/typebox": "^0.34.41"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengsd/rpc-client",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "description": "Standalone RPC client SDK for GSD — zero internal dependencies",
5
5
  "license": "MIT",
6
6
  "gsd": {
@@ -34,7 +34,7 @@
34
34
  "test": "node --test dist/rpc-client.test.js"
35
35
  },
36
36
  "dependencies": {
37
- "@opengsd/contracts": "^1.6.0"
37
+ "@opengsd/contracts": "^1.7.0"
38
38
  },
39
39
  "engines": {
40
40
  "node": ">=22.0.0"
package/pkg/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glittercowboy/gsd",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "piConfig": {
5
5
  "name": "gsd",
6
6
  "configDir": ".gsd"
@@ -9,6 +9,7 @@ import { dirname, join } from "node:path";
9
9
  const {
10
10
  resolveBundledGsdBrowserCliPath,
11
11
  resolveGsdBrowserDaemonStartInvocation,
12
+ resolveGsdBrowserDaemonStopInvocation,
12
13
  resolveGsdBrowserMcpLaunchConfig,
13
14
  } = await import("../../shared/gsd-browser-cli.ts");
14
15
 
@@ -179,3 +180,21 @@ describe("resolveGsdBrowserDaemonStartInvocation", () => {
179
180
  );
180
181
  });
181
182
  });
183
+
184
+ describe("resolveGsdBrowserDaemonStopInvocation", () => {
185
+ it("mirrors MCP session and identity flags with daemon stop", () => {
186
+ const launch = resolveGsdBrowserMcpLaunchConfig("/tmp/example-project", {});
187
+ const daemon = resolveGsdBrowserDaemonStopInvocation("/tmp/example-project", {});
188
+
189
+ assert.equal(daemon.command, launch.command);
190
+ assert.equal(daemon.cwd, launch.cwd);
191
+ assert.deepEqual(
192
+ daemon.args.slice(daemon.args.indexOf("--session")),
193
+ launch.args.slice(launch.args.indexOf("--session")),
194
+ );
195
+ assert.deepEqual(
196
+ daemon.args.slice(0, daemon.args.indexOf("--session")),
197
+ [...launch.args.slice(0, launch.args.indexOf("mcp")), "daemon", "stop"],
198
+ );
199
+ });
200
+ });
@@ -56,7 +56,7 @@ import { normalizeWorktreePathForCompare } from "./worktree-root.js";
56
56
  import { isDbAvailable, getTask, getSlice, getMilestone, getMilestoneSlices, updateTaskStatus, _getAdapter, getVerificationEvidence } from "./gsd-db.js";
57
57
  import { getWorkflowDatabasePath, refreshWorkflowDatabaseFromDisk } from "./db-workspace.js";
58
58
  import { renderPlanCheckboxes, renderRoadmapFromDb, roadmapRenderMarksSliceDone } from "./markdown-renderer.js";
59
- import { consumeSignal } from "./session-status-io.js";
59
+ import { awaitWorkerResume, consumeSignal } from "./session-status-io.js";
60
60
  import {
61
61
  checkPostUnitHooks,
62
62
  consumeHookFailure,
@@ -1279,8 +1279,27 @@ export async function postUnitPreVerification(pctx: PostUnitContext, opts?: PreV
1279
1279
  return "dispatched";
1280
1280
  }
1281
1281
  if (signal.signal === "pause") {
1282
- await pauseAuto(ctx, pi);
1283
- return "dispatched";
1282
+ // A worker `pause` is a *temporary* coordination request: the docs
1283
+ // contract (docs/user-docs/parallel-orchestration.md) is "finish current
1284
+ // unit, wait" followed by a matching `resume`. Only the interactive
1285
+ // dashboard runtime owns a resumer — unattended `gsd headless auto` does
1286
+ // not — so a terminal pauseAuto() here strands the worker at exit 10
1287
+ // (BLOCKED) after its first unit, with nothing able to lift it (#1273).
1288
+ // Wait for the coordinator to resume (or stop) us instead. If no resumer
1289
+ // lifts the pause within the window, degrade to in-process serialization
1290
+ // and continue the dispatch loop rather than halting the run forever.
1291
+ const resumeOutcome = await awaitWorkerResume(s.basePath, milestoneLock);
1292
+ if (resumeOutcome === "stop") {
1293
+ await stopAuto(ctx, pi);
1294
+ return "dispatched";
1295
+ }
1296
+ if (resumeOutcome === "timeout") {
1297
+ logWarning(
1298
+ "parallel",
1299
+ `pause signal for ${milestoneLock} was not resumed (no headless resumer, #1273); continuing dispatch`,
1300
+ );
1301
+ }
1302
+ // "resume" or "timeout": fall through and continue post-unit processing.
1284
1303
  }
1285
1304
  }
1286
1305
  }
@@ -2725,6 +2725,26 @@ export async function buildExecuteTaskPrompt(
2725
2725
  let phaseAnchorSection = planAnchor ? formatAnchorForPrompt(planAnchor) : "";
2726
2726
  trackPromptContext(contextTelemetry, "plan-anchor", phaseAnchorSection ? "inline" : "skipped", phaseAnchorSection, phaseAnchorSection ? undefined : "missing");
2727
2727
 
2728
+ // #1272: inject a pending reopen reason into this task's prompt. When a gate
2729
+ // (e.g. complete-slice's full-suite run) reopened the task via gsd_task_reopen
2730
+ // with a diagnosis, surface it here so the re-dispatched executor fixes the
2731
+ // regression instead of re-running the original (green) scoped verify. Claim
2732
+ // is one-shot (artifact deleted on read). Not feature-gated — a reopened task
2733
+ // must always know why. Prepended so it sits above the plan anchor.
2734
+ try {
2735
+ const { claimReopenReasonForInjection } = await import("./reopen-reason.js");
2736
+ const reopenClaimed = claimReopenReasonForInjection(base, mid, sid, tid);
2737
+ if (reopenClaimed) {
2738
+ const block = reopenClaimed.injectionBlock + "\n\n---\n\n";
2739
+ phaseAnchorSection = phaseAnchorSection
2740
+ ? `${block}${phaseAnchorSection}`
2741
+ : block;
2742
+ trackPromptContext(contextTelemetry, "reopen-reason", "inline", reopenClaimed.injectionBlock);
2743
+ }
2744
+ } catch (reopenErr) {
2745
+ logWarning("prompt", `reopen reason injection failed: ${(reopenErr as Error).message}`);
2746
+ }
2747
+
2728
2748
  // ADR-011 Phase 2: inject any resolved-but-unapplied escalation override
2729
2749
  // into this task's prompt. Claim is atomic via DB UPDATE WHERE IS NULL, so
2730
2750
  // if a parallel build already injected it, we skip. Feature-gated by
@@ -185,6 +185,7 @@ import { enterAutoWorktree, isInAutoWorktree } from "./auto-worktree-entry.js";
185
185
  import { getAutoWorktreePath } from "./auto-worktree-path-resolution.js";
186
186
  import { checkResourcesStale, readResourceVersion } from "./auto-worktree-resource-version.js";
187
187
  import { escapeStaleWorktree } from "./auto-worktree-runtime-cleanup.js";
188
+ import { teardownWarmedBrowserDaemons } from "./browser-daemon-auto-prep.js";
188
189
  import { getAutoWorktreeOriginalBase } from "./auto-worktree-session-registry.js";
189
190
  import { syncWorktreeStateBack } from "./auto-worktree-sync.js";
190
191
  import { teardownAutoWorktree } from "./auto-worktree-teardown.js";
@@ -1490,6 +1491,17 @@ export async function cleanupAfterLoopExit(ctx: ExtensionContext): Promise<void>
1490
1491
  logWarning("session", `lock cleanup failed: ${err instanceof Error ? err.message : String(err)}`, { file: "auto.ts" });
1491
1492
  }
1492
1493
 
1494
+ // Symmetric teardown for the browser-UAT warm-up preflight (#1259): stop any
1495
+ // gsd-browser daemon this session started so the Chrome process does not
1496
+ // linger after the loop exits. Re-warms on the next browser-backed run-uat.
1497
+ try {
1498
+ for (const { projectRoot, error } of teardownWarmedBrowserDaemons()) {
1499
+ logWarning("session", `gsd-browser daemon stop failed for ${projectRoot}: ${error}`, { file: "auto.ts" });
1500
+ }
1501
+ } catch (err) {
1502
+ logWarning("session", `browser daemon teardown failed: ${err instanceof Error ? err.message : String(err)}`, { file: "auto.ts" });
1503
+ }
1504
+
1493
1505
  // A transient provider-error pause intentionally leaves the paused badge
1494
1506
  // visible so the user still has a resumable auto-mode signal on screen.
1495
1507
  if (!s.paused) {
@@ -9,10 +9,19 @@ import {
9
9
  import {
10
10
  resolveGsdBrowserCliAvailability,
11
11
  resolveGsdBrowserDaemonStartInvocation,
12
+ resolveGsdBrowserDaemonStopInvocation,
12
13
  } from "../shared/gsd-browser-cli.js";
13
14
  import { uatTypeIncludesBrowser, type UatType } from "./uat-policy.js";
14
15
 
15
16
  const DEFAULT_DAEMON_START_TIMEOUT_MS = 30_000;
17
+ const DEFAULT_DAEMON_STOP_TIMEOUT_MS = 15_000;
18
+
19
+ /**
20
+ * Project roots whose gsd-browser daemon this process warmed via
21
+ * {@link prepareBrowserDaemonForUat}. Teardown only stops daemons we actually
22
+ * started, so non-browser auto-mode sessions never spawn a stray `daemon stop`.
23
+ */
24
+ const warmedDaemonProjectRoots = new Set<string>();
16
25
 
17
26
  function isEnvDisabled(value: string | undefined): boolean {
18
27
  if (!value) return false;
@@ -102,7 +111,74 @@ export function prepareBrowserDaemonForUat(ctx: BrowserDaemonWarmContext): strin
102
111
  if (!shouldWarmBrowserDaemonForUat(ctx)) return null;
103
112
 
104
113
  const result = ensureBrowserDaemonStarted(ctx.projectRoot, { env: ctx.env });
105
- if (result.ok) return null;
114
+ if (result.ok) {
115
+ warmedDaemonProjectRoots.add(resolve(ctx.projectRoot));
116
+ return null;
117
+ }
106
118
 
107
119
  return `Cannot dispatch browser-backed run-uat: gsd-browser daemon failed to start (${result.error}). Ensure Chrome/Chromium is installed, run \`gsd-browser daemon health\` with the project session flags from .mcp.json, or set GSD_BROWSER_PATH to a Chromium binary.`;
108
120
  }
121
+
122
+ /**
123
+ * Best-effort stop of a gsd-browser session daemon warmed for browser UAT.
124
+ * Mirrors {@link ensureBrowserDaemonStarted}; returns the failure detail rather
125
+ * than throwing so teardown can stay non-fatal.
126
+ */
127
+ export function stopBrowserDaemon(
128
+ projectRoot: string,
129
+ options: { env?: NodeJS.ProcessEnv; timeoutMs?: number } = {},
130
+ ): { ok: true } | { ok: false; error: string } {
131
+ const env = options.env ?? process.env;
132
+ const availability = resolveGsdBrowserCliAvailability(env);
133
+ if (!availability.available) {
134
+ return { ok: false, error: availability.detail };
135
+ }
136
+
137
+ let invocation: ReturnType<typeof resolveGsdBrowserDaemonStopInvocation>;
138
+ try {
139
+ invocation = resolveGsdBrowserDaemonStopInvocation(projectRoot, env);
140
+ } catch (error) {
141
+ return {
142
+ ok: false,
143
+ error: error instanceof Error ? error.message : String(error),
144
+ };
145
+ }
146
+
147
+ try {
148
+ execFileSync(invocation.command, invocation.args, {
149
+ cwd: invocation.cwd,
150
+ env: { ...process.env, ...env, ...(invocation.env ?? {}) },
151
+ stdio: ["ignore", "pipe", "pipe"],
152
+ timeout: options.timeoutMs ?? DEFAULT_DAEMON_STOP_TIMEOUT_MS,
153
+ encoding: "utf-8",
154
+ });
155
+ return { ok: true };
156
+ } catch (error) {
157
+ return {
158
+ ok: false,
159
+ error: error instanceof Error ? error.message : String(error),
160
+ };
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Tear down every gsd-browser daemon this process warmed for browser UAT,
166
+ * clearing the tracked set. Called on auto-mode loop exit so the Chrome process
167
+ * does not survive session/task completion (mirrors the warm-up preflight).
168
+ * Best-effort: returns the project roots whose stop failed for logging.
169
+ */
170
+ export function teardownWarmedBrowserDaemons(
171
+ options: { env?: NodeJS.ProcessEnv } = {},
172
+ ): { projectRoot: string; error: string }[] {
173
+ if (warmedDaemonProjectRoots.size === 0) return [];
174
+
175
+ const failures: { projectRoot: string; error: string }[] = [];
176
+ for (const projectRoot of warmedDaemonProjectRoots) {
177
+ const result = stopBrowserDaemon(projectRoot, { env: options.env });
178
+ if (!result.ok) {
179
+ failures.push({ projectRoot, error: result.error });
180
+ }
181
+ }
182
+ warmedDaemonProjectRoots.clear();
183
+ return failures;
184
+ }
@@ -0,0 +1,109 @@
1
+ // Project/App: gsd-pi
2
+ // File Purpose: Persist a reopened task's failure diagnosis and inject it into the re-dispatched execute-task prompt.
3
+ // GSD Extension — follow-up to #1225 (#1272)
4
+ //
5
+ // When complete-slice's gate reopens a task via gsd_task_reopen, the operator
6
+ // supplies a `reason` explaining exactly what the full-suite gate caught and
7
+ // how to fix it. That reason previously only landed in the workflow event log
8
+ // (audit trail) and never reached the re-dispatched executor, so the executor
9
+ // re-ran the original (green) verify and re-completed the task without touching
10
+ // the regression. This module is the "inject unresolved context into the next
11
+ // dispatch" mechanism for reopen reasons — a lightweight, file-based sibling of
12
+ // escalation.js#claimOverrideForInjection, keyed by (milestone, slice, task).
13
+
14
+ import { existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
15
+ import { join } from "node:path";
16
+
17
+ import { legacyMilestonesDir, resolveSlicePath, resolveTasksDir } from "./paths.js";
18
+ import { atomicWriteSync } from "./atomic-write.js";
19
+ import { logWarning } from "./workflow-logger.js";
20
+
21
+ interface ReopenReasonArtifact {
22
+ version: 1;
23
+ milestoneId: string;
24
+ sliceId: string;
25
+ taskId: string;
26
+ reason: string;
27
+ createdAt: string;
28
+ }
29
+
30
+ /**
31
+ * Canonical reopen-reason artifact path, parallel to T##-SUMMARY.md and
32
+ * T##-ESCALATION.json:
33
+ * .gsd/milestones/{M}/slices/{S}/tasks/{T}-REOPEN.json
34
+ * Flat-phase: the artifact sits directly in the phase dir. Legacy layouts
35
+ * without a tasks/ subdir return null (caller degrades gracefully).
36
+ */
37
+ export function reopenReasonArtifactPath(
38
+ basePath: string, milestoneId: string, sliceId: string, taskId: string,
39
+ ): string | null {
40
+ const tDir = resolveTasksDir(basePath, milestoneId, sliceId);
41
+ if (tDir) return join(tDir, `${taskId}-REOPEN.json`);
42
+ if (existsSync(legacyMilestonesDir(basePath))) return null;
43
+ const phaseDir = resolveSlicePath(basePath, milestoneId, sliceId);
44
+ if (!phaseDir) return null;
45
+ return join(phaseDir, `${taskId}-REOPEN.json`);
46
+ }
47
+
48
+ /**
49
+ * Persist the reopen reason so the next execute-task dispatch can surface it.
50
+ * A no-op when the reason is empty or the artifact path cannot be resolved —
51
+ * reopen itself must still succeed even if we cannot attach context.
52
+ */
53
+ export function writeReopenReason(
54
+ basePath: string, milestoneId: string, sliceId: string, taskId: string, reason: string,
55
+ ): void {
56
+ const trimmed = reason.trim();
57
+ if (!trimmed) return;
58
+ const path = reopenReasonArtifactPath(basePath, milestoneId, sliceId, taskId);
59
+ if (!path) {
60
+ logWarning("tool", `reopen-reason: cannot resolve artifact path for ${milestoneId}/${sliceId}/${taskId}; reopen reason not attached to next dispatch`);
61
+ return;
62
+ }
63
+ const artifact: ReopenReasonArtifact = {
64
+ version: 1,
65
+ milestoneId, sliceId, taskId,
66
+ reason: trimmed,
67
+ createdAt: new Date().toISOString(),
68
+ };
69
+ mkdirSync(join(path, ".."), { recursive: true });
70
+ atomicWriteSync(path, JSON.stringify(artifact, null, 2));
71
+ }
72
+
73
+ /**
74
+ * If a reopen reason is pending for this task, claim it (one-shot: the artifact
75
+ * is deleted on read) and return the markdown block to prepend to the executor's
76
+ * prompt. Returns null when nothing is pending or the artifact is malformed.
77
+ */
78
+ export function claimReopenReasonForInjection(
79
+ basePath: string, milestoneId: string, sliceId: string, taskId: string,
80
+ ): { injectionBlock: string } | null {
81
+ const path = reopenReasonArtifactPath(basePath, milestoneId, sliceId, taskId);
82
+ if (!path || !existsSync(path)) return null;
83
+ let art: ReopenReasonArtifact | null = null;
84
+ try {
85
+ const parsed = JSON.parse(readFileSync(path, "utf-8")) as Partial<ReopenReasonArtifact>;
86
+ if (parsed && parsed.version === 1 && typeof parsed.reason === "string" && parsed.reason.trim()) {
87
+ art = parsed as ReopenReasonArtifact;
88
+ }
89
+ } catch {
90
+ art = null;
91
+ }
92
+ // Claim (delete) regardless of validity so a malformed artifact doesn't
93
+ // wedge every future dispatch of this task.
94
+ try { unlinkSync(path); } catch { /* already gone */ }
95
+ if (!art) return null;
96
+ return { injectionBlock: formatReopenReasonBlock(art) };
97
+ }
98
+
99
+ function formatReopenReasonBlock(art: ReopenReasonArtifact): string {
100
+ return [
101
+ `## Reopened — Reason (from ${art.taskId})`,
102
+ "",
103
+ "This task was previously marked complete but a downstream gate (e.g. complete-slice's full-suite run) reopened it. The verify command in your task plan passed in isolation — it did **not** catch the regression below. Do **not** call `gsd_task_complete` until you have reproduced and fixed exactly what this diagnosis describes, then re-run the specific failing command it names (not just the original scoped verify).",
104
+ "",
105
+ "**Diagnosis / fix instructions from the gate:**",
106
+ "",
107
+ art.reason.trim(),
108
+ ].join("\n");
109
+ }
@@ -48,6 +48,11 @@ const PARALLEL_DIR = "parallel";
48
48
  const STATUS_SUFFIX = ".status.json";
49
49
  const SIGNAL_SUFFIX = ".signal.json";
50
50
  const DEFAULT_STALE_TIMEOUT_MS = 30_000;
51
+ // How long a paused worker waits for the coordinator to lift the pause before
52
+ // it degrades to in-process serialization (#1273). Kept below the stale
53
+ // timeout so the wait never races the coordinator's liveness detection.
54
+ const DEFAULT_RESUME_WAIT_MS = 10_000;
55
+ const DEFAULT_RESUME_POLL_MS = 250;
51
56
 
52
57
  function isSessionStatus(data: unknown): data is SessionStatus {
53
58
  return data !== null && typeof data === "object" && "milestoneId" in data && "pid" in data;
@@ -143,6 +148,35 @@ export function consumeSignal(basePath: string, milestoneId: string): SignalMess
143
148
  return msg;
144
149
  }
145
150
 
151
+ /**
152
+ * Wait for a coordinator to lift a `pause` on a worker by sending `resume`
153
+ * (or `stop`). Polls the signal file until one of those arrives or the timeout
154
+ * elapses. Intervening `pause`/`rebase` signals are consumed and ignored so a
155
+ * repeated pause doesn't reset the wait.
156
+ *
157
+ * A worker's `pause` is only ever lifted by the interactive/dashboard resumer;
158
+ * unattended `gsd headless auto` owns no resumer, so callers use the `"timeout"`
159
+ * result to degrade to in-process serialization instead of stranding the worker
160
+ * at a terminal pause it cannot resume (#1273).
161
+ */
162
+ export async function awaitWorkerResume(
163
+ basePath: string,
164
+ milestoneId: string,
165
+ opts: { timeoutMs?: number; pollMs?: number } = {},
166
+ ): Promise<"resume" | "stop" | "timeout"> {
167
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_RESUME_WAIT_MS;
168
+ const pollMs = opts.pollMs ?? DEFAULT_RESUME_POLL_MS;
169
+ const deadline = Date.now() + timeoutMs;
170
+
171
+ for (;;) {
172
+ const msg = consumeSignal(basePath, milestoneId);
173
+ if (msg?.signal === "resume") return "resume";
174
+ if (msg?.signal === "stop") return "stop";
175
+ if (Date.now() >= deadline) return "timeout";
176
+ await new Promise((resolve) => setTimeout(resolve, pollMs));
177
+ }
178
+ }
179
+
146
180
  // ─── Stale Detection ───────────────────────────────────────────────────────
147
181
 
148
182
  /** Check whether a session is stale (PID dead or heartbeat timed out). */
@@ -4,6 +4,8 @@ import assert from "node:assert/strict";
4
4
  import {
5
5
  prepareBrowserDaemonForUat,
6
6
  shouldWarmBrowserDaemonForUat,
7
+ stopBrowserDaemon,
8
+ teardownWarmedBrowserDaemons,
7
9
  } from "../browser-daemon-auto-prep.ts";
8
10
  import { commitBrowserEngineResolution } from "../../browser-tools/engine/selection.ts";
9
11
  import { resolveGsdBrowserCliAvailability } from "../../shared/gsd-browser-cli.ts";
@@ -142,3 +144,27 @@ test("prepareBrowserDaemonForUat returns actionable error when daemon start fail
142
144
 
143
145
  assert.match(error ?? "", /gsd-browser daemon failed to start/i);
144
146
  });
147
+
148
+ test("stopBrowserDaemon reports failure when the gsd-browser CLI is missing", () => {
149
+ const result = stopBrowserDaemon("/tmp/example-project", {
150
+ env: { GSD_BROWSER_MCP_COMMAND: "/definitely/missing/gsd-browser" },
151
+ });
152
+
153
+ assert.equal(result.ok, false);
154
+ });
155
+
156
+ test("teardownWarmedBrowserDaemons is a no-op when nothing was warmed", () => {
157
+ // A failed warm-up must not register a project root for teardown.
158
+ prepareBrowserDaemonForUat({
159
+ uatType: "browser-executable",
160
+ sessionProvider: "claude-code",
161
+ sessionAuthMode: "externalCli",
162
+ projectRoot: "/tmp/example-project",
163
+ env: {
164
+ ...GSD_BROWSER_ENGINE,
165
+ GSD_BROWSER_MCP_COMMAND: "/definitely/missing/gsd-browser",
166
+ },
167
+ });
168
+
169
+ assert.deepEqual(teardownWarmedBrowserDaemons(), []);
170
+ });