@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
package/README.md CHANGED
@@ -28,16 +28,12 @@ See [CHANGELOG.md](./CHANGELOG.md) for release-by-release fixes and [Legacy Rele
28
28
  ## Latest Release Highlights
29
29
 
30
30
  <!-- release-highlights:start -->
31
- Latest release: **v1.6.0**
32
-
33
- - **skills:** Load .claude/skills into the catalog (mirror Claude Code).
34
- - **gsd:** Extract uat dispatch discovery.
35
- - **daemon:** Split orchestrator agent loop.
36
- - **daemon:** Extract orchestrator tool execution.
37
- - **uok:** Scope audit degradation to kernel workspace.
38
- - **browser-tools:** Abort pending daemon starts on close.
39
- - **browser-tools:** Guard managed daemon connection lifecycle.
40
- - **worktree:** Centralize changed scan.
31
+ Latest release: **v1.7.0**
32
+
33
+ - **cloud:** Add device-flow login to daemon + @opengsd/gsd-cloud agent (#1278).
34
+ - **issue:** [Bug]: Unattended `gsd headless auto` has no resumer for the parallel/slice_parallel worker pause signal → terminal exit 10 after the first unit.
35
+ - **issue:** Complete-slice's reopen reason never reaches the re-dispatched execute-task (follow-up to #1225).
36
+ - **issue:** [Feature Request]: gsd-browser daemon has no automatic teardown — orphaned Chrome process survives session/task completion.
41
37
 
42
38
  <!-- release-highlights:end -->
43
39
 
@@ -1 +1 @@
1
- d89940a8e727f2ea
1
+ 1995c618cd0898a5
@@ -31,7 +31,7 @@ import { normalizeWorktreePathForCompare } from "./worktree-root.js";
31
31
  import { isDbAvailable, getTask, getSlice, getMilestone, getMilestoneSlices, updateTaskStatus, _getAdapter, getVerificationEvidence } from "./gsd-db.js";
32
32
  import { getWorkflowDatabasePath, refreshWorkflowDatabaseFromDisk } from "./db-workspace.js";
33
33
  import { renderPlanCheckboxes, renderRoadmapFromDb, roadmapRenderMarksSliceDone } from "./markdown-renderer.js";
34
- import { consumeSignal } from "./session-status-io.js";
34
+ import { awaitWorkerResume, consumeSignal } from "./session-status-io.js";
35
35
  import { checkPostUnitHooks, consumeHookFailure, isRetryPending, consumeRetryTrigger, consumeGateBlock, persistHookState, resolveHookArtifactPath, } from "./post-unit-hooks.js";
36
36
  import { hasPendingCaptures, loadPendingCaptures, revertExecutorResolvedCaptures } from "./captures.js";
37
37
  import { debugLog } from "./debug-logger.js";
@@ -1051,8 +1051,24 @@ export async function postUnitPreVerification(pctx, opts) {
1051
1051
  return "dispatched";
1052
1052
  }
1053
1053
  if (signal.signal === "pause") {
1054
- await pauseAuto(ctx, pi);
1055
- return "dispatched";
1054
+ // A worker `pause` is a *temporary* coordination request: the docs
1055
+ // contract (docs/user-docs/parallel-orchestration.md) is "finish current
1056
+ // unit, wait" followed by a matching `resume`. Only the interactive
1057
+ // dashboard runtime owns a resumer — unattended `gsd headless auto` does
1058
+ // not — so a terminal pauseAuto() here strands the worker at exit 10
1059
+ // (BLOCKED) after its first unit, with nothing able to lift it (#1273).
1060
+ // Wait for the coordinator to resume (or stop) us instead. If no resumer
1061
+ // lifts the pause within the window, degrade to in-process serialization
1062
+ // and continue the dispatch loop rather than halting the run forever.
1063
+ const resumeOutcome = await awaitWorkerResume(s.basePath, milestoneLock);
1064
+ if (resumeOutcome === "stop") {
1065
+ await stopAuto(ctx, pi);
1066
+ return "dispatched";
1067
+ }
1068
+ if (resumeOutcome === "timeout") {
1069
+ logWarning("parallel", `pause signal for ${milestoneLock} was not resumed (no headless resumer, #1273); continuing dispatch`);
1070
+ }
1071
+ // "resume" or "timeout": fall through and continue post-unit processing.
1056
1072
  }
1057
1073
  }
1058
1074
  }
@@ -2299,6 +2299,26 @@ export async function buildExecuteTaskPrompt(mid, sid, sTitle, tid, tTitle, base
2299
2299
  trackPromptContext(contextTelemetry, "runtime", runtimeContext ? "inline" : "skipped", runtimeContext, runtimeContext ? undefined : "missing");
2300
2300
  let phaseAnchorSection = planAnchor ? formatAnchorForPrompt(planAnchor) : "";
2301
2301
  trackPromptContext(contextTelemetry, "plan-anchor", phaseAnchorSection ? "inline" : "skipped", phaseAnchorSection, phaseAnchorSection ? undefined : "missing");
2302
+ // #1272: inject a pending reopen reason into this task's prompt. When a gate
2303
+ // (e.g. complete-slice's full-suite run) reopened the task via gsd_task_reopen
2304
+ // with a diagnosis, surface it here so the re-dispatched executor fixes the
2305
+ // regression instead of re-running the original (green) scoped verify. Claim
2306
+ // is one-shot (artifact deleted on read). Not feature-gated — a reopened task
2307
+ // must always know why. Prepended so it sits above the plan anchor.
2308
+ try {
2309
+ const { claimReopenReasonForInjection } = await import("./reopen-reason.js");
2310
+ const reopenClaimed = claimReopenReasonForInjection(base, mid, sid, tid);
2311
+ if (reopenClaimed) {
2312
+ const block = reopenClaimed.injectionBlock + "\n\n---\n\n";
2313
+ phaseAnchorSection = phaseAnchorSection
2314
+ ? `${block}${phaseAnchorSection}`
2315
+ : block;
2316
+ trackPromptContext(contextTelemetry, "reopen-reason", "inline", reopenClaimed.injectionBlock);
2317
+ }
2318
+ }
2319
+ catch (reopenErr) {
2320
+ logWarning("prompt", `reopen reason injection failed: ${reopenErr.message}`);
2321
+ }
2302
2322
  // ADR-011 Phase 2: inject any resolved-but-unapplied escalation override
2303
2323
  // into this task's prompt. Claim is atomic via DB UPDATE WHERE IS NULL, so
2304
2324
  // if a parallel build already injected it, we skip. Feature-gated by
@@ -58,6 +58,7 @@ import { isInAutoWorktree } from "./auto-worktree-entry.js";
58
58
  import { getAutoWorktreePath } from "./auto-worktree-path-resolution.js";
59
59
  import { checkResourcesStale } from "./auto-worktree-resource-version.js";
60
60
  import { escapeStaleWorktree } from "./auto-worktree-runtime-cleanup.js";
61
+ import { teardownWarmedBrowserDaemons } from "./browser-daemon-auto-prep.js";
61
62
  import { teardownAutoWorktree } from "./auto-worktree-teardown.js";
62
63
  import { pruneQueueOrder } from "./queue-order.js";
63
64
  import { startCommandPolling as _startCommandPolling, isRemoteConfigured } from "../remote-questions/manager.js";
@@ -1003,6 +1004,17 @@ export async function cleanupAfterLoopExit(ctx) {
1003
1004
  /* best-effort — mirror stopAuto cleanup */
1004
1005
  logWarning("session", `lock cleanup failed: ${err instanceof Error ? err.message : String(err)}`, { file: "auto.ts" });
1005
1006
  }
1007
+ // Symmetric teardown for the browser-UAT warm-up preflight (#1259): stop any
1008
+ // gsd-browser daemon this session started so the Chrome process does not
1009
+ // linger after the loop exits. Re-warms on the next browser-backed run-uat.
1010
+ try {
1011
+ for (const { projectRoot, error } of teardownWarmedBrowserDaemons()) {
1012
+ logWarning("session", `gsd-browser daemon stop failed for ${projectRoot}: ${error}`, { file: "auto.ts" });
1013
+ }
1014
+ }
1015
+ catch (err) {
1016
+ logWarning("session", `browser daemon teardown failed: ${err instanceof Error ? err.message : String(err)}`, { file: "auto.ts" });
1017
+ }
1006
1018
  // A transient provider-error pause intentionally leaves the paused badge
1007
1019
  // visible so the user still has a resumable auto-mode signal on screen.
1008
1020
  if (!s.paused) {
@@ -1,9 +1,16 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import { resolve } from "node:path";
3
3
  import { resolveAmbientBrowserEngineResolution, resolveBrowserEngineResolution, } from "../browser-tools/engine/selection.js";
4
- import { resolveGsdBrowserCliAvailability, resolveGsdBrowserDaemonStartInvocation, } from "../shared/gsd-browser-cli.js";
4
+ import { resolveGsdBrowserCliAvailability, resolveGsdBrowserDaemonStartInvocation, resolveGsdBrowserDaemonStopInvocation, } from "../shared/gsd-browser-cli.js";
5
5
  import { uatTypeIncludesBrowser } from "./uat-policy.js";
6
6
  const DEFAULT_DAEMON_START_TIMEOUT_MS = 30_000;
7
+ const DEFAULT_DAEMON_STOP_TIMEOUT_MS = 15_000;
8
+ /**
9
+ * Project roots whose gsd-browser daemon this process warmed via
10
+ * {@link prepareBrowserDaemonForUat}. Teardown only stops daemons we actually
11
+ * started, so non-browser auto-mode sessions never spawn a stray `daemon stop`.
12
+ */
13
+ const warmedDaemonProjectRoots = new Set();
7
14
  function isEnvDisabled(value) {
8
15
  if (!value)
9
16
  return false;
@@ -77,7 +84,66 @@ export function prepareBrowserDaemonForUat(ctx) {
77
84
  if (!shouldWarmBrowserDaemonForUat(ctx))
78
85
  return null;
79
86
  const result = ensureBrowserDaemonStarted(ctx.projectRoot, { env: ctx.env });
80
- if (result.ok)
87
+ if (result.ok) {
88
+ warmedDaemonProjectRoots.add(resolve(ctx.projectRoot));
81
89
  return null;
90
+ }
82
91
  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.`;
83
92
  }
93
+ /**
94
+ * Best-effort stop of a gsd-browser session daemon warmed for browser UAT.
95
+ * Mirrors {@link ensureBrowserDaemonStarted}; returns the failure detail rather
96
+ * than throwing so teardown can stay non-fatal.
97
+ */
98
+ export function stopBrowserDaemon(projectRoot, options = {}) {
99
+ const env = options.env ?? process.env;
100
+ const availability = resolveGsdBrowserCliAvailability(env);
101
+ if (!availability.available) {
102
+ return { ok: false, error: availability.detail };
103
+ }
104
+ let invocation;
105
+ try {
106
+ invocation = resolveGsdBrowserDaemonStopInvocation(projectRoot, env);
107
+ }
108
+ catch (error) {
109
+ return {
110
+ ok: false,
111
+ error: error instanceof Error ? error.message : String(error),
112
+ };
113
+ }
114
+ try {
115
+ execFileSync(invocation.command, invocation.args, {
116
+ cwd: invocation.cwd,
117
+ env: { ...process.env, ...env, ...(invocation.env ?? {}) },
118
+ stdio: ["ignore", "pipe", "pipe"],
119
+ timeout: options.timeoutMs ?? DEFAULT_DAEMON_STOP_TIMEOUT_MS,
120
+ encoding: "utf-8",
121
+ });
122
+ return { ok: true };
123
+ }
124
+ catch (error) {
125
+ return {
126
+ ok: false,
127
+ error: error instanceof Error ? error.message : String(error),
128
+ };
129
+ }
130
+ }
131
+ /**
132
+ * Tear down every gsd-browser daemon this process warmed for browser UAT,
133
+ * clearing the tracked set. Called on auto-mode loop exit so the Chrome process
134
+ * does not survive session/task completion (mirrors the warm-up preflight).
135
+ * Best-effort: returns the project roots whose stop failed for logging.
136
+ */
137
+ export function teardownWarmedBrowserDaemons(options = {}) {
138
+ if (warmedDaemonProjectRoots.size === 0)
139
+ return [];
140
+ const failures = [];
141
+ for (const projectRoot of warmedDaemonProjectRoots) {
142
+ const result = stopBrowserDaemon(projectRoot, { env: options.env });
143
+ if (!result.ok) {
144
+ failures.push({ projectRoot, error: result.error });
145
+ }
146
+ }
147
+ warmedDaemonProjectRoots.clear();
148
+ return failures;
149
+ }
@@ -0,0 +1,98 @@
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
+ import { existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
14
+ import { join } from "node:path";
15
+ import { legacyMilestonesDir, resolveSlicePath, resolveTasksDir } from "./paths.js";
16
+ import { atomicWriteSync } from "./atomic-write.js";
17
+ import { logWarning } from "./workflow-logger.js";
18
+ /**
19
+ * Canonical reopen-reason artifact path, parallel to T##-SUMMARY.md and
20
+ * T##-ESCALATION.json:
21
+ * .gsd/milestones/{M}/slices/{S}/tasks/{T}-REOPEN.json
22
+ * Flat-phase: the artifact sits directly in the phase dir. Legacy layouts
23
+ * without a tasks/ subdir return null (caller degrades gracefully).
24
+ */
25
+ export function reopenReasonArtifactPath(basePath, milestoneId, sliceId, taskId) {
26
+ const tDir = resolveTasksDir(basePath, milestoneId, sliceId);
27
+ if (tDir)
28
+ return join(tDir, `${taskId}-REOPEN.json`);
29
+ if (existsSync(legacyMilestonesDir(basePath)))
30
+ return null;
31
+ const phaseDir = resolveSlicePath(basePath, milestoneId, sliceId);
32
+ if (!phaseDir)
33
+ return null;
34
+ return join(phaseDir, `${taskId}-REOPEN.json`);
35
+ }
36
+ /**
37
+ * Persist the reopen reason so the next execute-task dispatch can surface it.
38
+ * A no-op when the reason is empty or the artifact path cannot be resolved —
39
+ * reopen itself must still succeed even if we cannot attach context.
40
+ */
41
+ export function writeReopenReason(basePath, milestoneId, sliceId, taskId, reason) {
42
+ const trimmed = reason.trim();
43
+ if (!trimmed)
44
+ return;
45
+ const path = reopenReasonArtifactPath(basePath, milestoneId, sliceId, taskId);
46
+ if (!path) {
47
+ logWarning("tool", `reopen-reason: cannot resolve artifact path for ${milestoneId}/${sliceId}/${taskId}; reopen reason not attached to next dispatch`);
48
+ return;
49
+ }
50
+ const artifact = {
51
+ version: 1,
52
+ milestoneId, sliceId, taskId,
53
+ reason: trimmed,
54
+ createdAt: new Date().toISOString(),
55
+ };
56
+ mkdirSync(join(path, ".."), { recursive: true });
57
+ atomicWriteSync(path, JSON.stringify(artifact, null, 2));
58
+ }
59
+ /**
60
+ * If a reopen reason is pending for this task, claim it (one-shot: the artifact
61
+ * is deleted on read) and return the markdown block to prepend to the executor's
62
+ * prompt. Returns null when nothing is pending or the artifact is malformed.
63
+ */
64
+ export function claimReopenReasonForInjection(basePath, milestoneId, sliceId, taskId) {
65
+ const path = reopenReasonArtifactPath(basePath, milestoneId, sliceId, taskId);
66
+ if (!path || !existsSync(path))
67
+ return null;
68
+ let art = null;
69
+ try {
70
+ const parsed = JSON.parse(readFileSync(path, "utf-8"));
71
+ if (parsed && parsed.version === 1 && typeof parsed.reason === "string" && parsed.reason.trim()) {
72
+ art = parsed;
73
+ }
74
+ }
75
+ catch {
76
+ art = null;
77
+ }
78
+ // Claim (delete) regardless of validity so a malformed artifact doesn't
79
+ // wedge every future dispatch of this task.
80
+ try {
81
+ unlinkSync(path);
82
+ }
83
+ catch { /* already gone */ }
84
+ if (!art)
85
+ return null;
86
+ return { injectionBlock: formatReopenReasonBlock(art) };
87
+ }
88
+ function formatReopenReasonBlock(art) {
89
+ return [
90
+ `## Reopened — Reason (from ${art.taskId})`,
91
+ "",
92
+ "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).",
93
+ "",
94
+ "**Diagnosis / fix instructions from the gate:**",
95
+ "",
96
+ art.reason.trim(),
97
+ ].join("\n");
98
+ }
@@ -18,6 +18,11 @@ const PARALLEL_DIR = "parallel";
18
18
  const STATUS_SUFFIX = ".status.json";
19
19
  const SIGNAL_SUFFIX = ".signal.json";
20
20
  const DEFAULT_STALE_TIMEOUT_MS = 30_000;
21
+ // How long a paused worker waits for the coordinator to lift the pause before
22
+ // it degrades to in-process serialization (#1273). Kept below the stale
23
+ // timeout so the wait never races the coordinator's liveness detection.
24
+ const DEFAULT_RESUME_WAIT_MS = 10_000;
25
+ const DEFAULT_RESUME_POLL_MS = 250;
21
26
  function isSessionStatus(data) {
22
27
  return data !== null && typeof data === "object" && "milestoneId" in data && "pid" in data;
23
28
  }
@@ -105,6 +110,32 @@ export function consumeSignal(basePath, milestoneId) {
105
110
  }
106
111
  return msg;
107
112
  }
113
+ /**
114
+ * Wait for a coordinator to lift a `pause` on a worker by sending `resume`
115
+ * (or `stop`). Polls the signal file until one of those arrives or the timeout
116
+ * elapses. Intervening `pause`/`rebase` signals are consumed and ignored so a
117
+ * repeated pause doesn't reset the wait.
118
+ *
119
+ * A worker's `pause` is only ever lifted by the interactive/dashboard resumer;
120
+ * unattended `gsd headless auto` owns no resumer, so callers use the `"timeout"`
121
+ * result to degrade to in-process serialization instead of stranding the worker
122
+ * at a terminal pause it cannot resume (#1273).
123
+ */
124
+ export async function awaitWorkerResume(basePath, milestoneId, opts = {}) {
125
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_RESUME_WAIT_MS;
126
+ const pollMs = opts.pollMs ?? DEFAULT_RESUME_POLL_MS;
127
+ const deadline = Date.now() + timeoutMs;
128
+ for (;;) {
129
+ const msg = consumeSignal(basePath, milestoneId);
130
+ if (msg?.signal === "resume")
131
+ return "resume";
132
+ if (msg?.signal === "stop")
133
+ return "stop";
134
+ if (Date.now() >= deadline)
135
+ return "timeout";
136
+ await new Promise((resolve) => setTimeout(resolve, pollMs));
137
+ }
138
+ }
108
139
  // ─── Stale Detection ───────────────────────────────────────────────────────
109
140
  /** Check whether a session is stale (PID dead or heartbeat timed out). */
110
141
  export function isSessionStale(status, timeoutMs = DEFAULT_STALE_TIMEOUT_MS) {
@@ -15,6 +15,7 @@ import { flushWorkflowProjections } from "../projection-flush.js";
15
15
  import { writeManifest } from "../workflow-manifest.js";
16
16
  import { appendEvent } from "../workflow-events.js";
17
17
  import { logWarning } from "../workflow-logger.js";
18
+ import { writeReopenReason } from "../reopen-reason.js";
18
19
  import { existsSync, unlinkSync } from "node:fs";
19
20
  import { join, basename, dirname } from "node:path";
20
21
  import { resolveTasksDir, resolveSlicePath, clearPathCache } from "../paths.js";
@@ -89,6 +90,20 @@ export async function handleReopenTask(params, basePath) {
89
90
  logWarning("tool", `reopen-task artifact cleanup warning: ${cleanupErr.message}`);
90
91
  }
91
92
  clearPathCache();
93
+ // ── Persist the reopen reason for the next execute-task dispatch (#1272) ──
94
+ // Without this, the re-dispatched executor receives the original task plan
95
+ // and verify command with zero signal that it was reopened — it re-runs the
96
+ // (still-green) scoped verify and re-completes the task, never touching the
97
+ // regression the gate actually caught. buildExecuteTaskPrompt claims this
98
+ // artifact and prepends the diagnosis to the next dispatch.
99
+ if (params.reason && params.reason.trim() !== "") {
100
+ try {
101
+ writeReopenReason(basePath, params.milestoneId, params.sliceId, params.taskId, params.reason);
102
+ }
103
+ catch (reasonErr) {
104
+ logWarning("tool", `reopen-task reason persistence warning: ${reasonErr.message}`);
105
+ }
106
+ }
92
107
  // ── Post-mutation hook ───────────────────────────────────────────────────
93
108
  try {
94
109
  await flushWorkflowProjections(basePath, { milestoneId: params.milestoneId });
@@ -307,11 +307,12 @@ export function resolveGsdBrowserMcpLaunchConfig(projectRoot, env = process.env,
307
307
  };
308
308
  }
309
309
  /**
310
- * CLI invocation that starts the gsd-browser session daemon with the same
311
- * session and identity flags as {@link resolveGsdBrowserMcpLaunchConfig}, so
312
- * browser UAT can warm Chrome/CDP before the first MCP navigation.
310
+ * CLI invocation that runs a `daemon <action>` command against the gsd-browser
311
+ * session daemon with the same session and identity flags as
312
+ * {@link resolveGsdBrowserMcpLaunchConfig}, so start (warm-up) and stop
313
+ * (teardown) address the exact same managed daemon.
313
314
  */
314
- export function resolveGsdBrowserDaemonStartInvocation(projectRoot, env = process.env, options = {}) {
315
+ function resolveGsdBrowserDaemonInvocation(action, projectRoot, env, options) {
315
316
  const launch = resolveGsdBrowserMcpLaunchConfig(projectRoot, env, options);
316
317
  const mcpIndex = launch.args.indexOf("mcp");
317
318
  if (mcpIndex < 0) {
@@ -321,8 +322,24 @@ export function resolveGsdBrowserDaemonStartInvocation(projectRoot, env = proces
321
322
  const sessionFlags = launch.args.slice(mcpIndex + 1);
322
323
  return {
323
324
  command: launch.command,
324
- args: [...prefix, "daemon", "start", ...sessionFlags],
325
+ args: [...prefix, "daemon", action, ...sessionFlags],
325
326
  cwd: launch.cwd,
326
327
  ...(launch.env ? { env: launch.env } : {}),
327
328
  };
328
329
  }
330
+ /**
331
+ * CLI invocation that starts the gsd-browser session daemon with the same
332
+ * session and identity flags as {@link resolveGsdBrowserMcpLaunchConfig}, so
333
+ * browser UAT can warm Chrome/CDP before the first MCP navigation.
334
+ */
335
+ export function resolveGsdBrowserDaemonStartInvocation(projectRoot, env = process.env, options = {}) {
336
+ return resolveGsdBrowserDaemonInvocation("start", projectRoot, env, options);
337
+ }
338
+ /**
339
+ * CLI invocation that stops the gsd-browser session daemon warmed for browser
340
+ * UAT, so the Chrome process is torn down instead of lingering after the
341
+ * triggering task/session ends.
342
+ */
343
+ export function resolveGsdBrowserDaemonStopInvocation(projectRoot, env = process.env, options = {}) {
344
+ return resolveGsdBrowserDaemonInvocation("stop", projectRoot, env, options);
345
+ }