feat(agents): add GSD phase guard to prevent subagent/phase conflicts

When GSD auto-mode is running a planning phase, the planner subagent
could bypass GSD's state machine and artifact system. This adds a
shared state module and conflict check to block agents that overlap
with the active GSD phase.

- Add shared/gsd-phase-state.ts for cross-extension phase coordination
- Add conflicts_with frontmatter field to agent definitions
- Block conflicting agents with clear error directing to GSD workflow
- Tag planner agent with conflicts_with for plan/research phases
- 10 new tests for phase state and conflict parsing
This commit is contained in:
Jeremy 2026-04-12 21:56:52 -05:00
parent 66f0d45a8c
commit 0c19ca88f2
8 changed files with 162 additions and 0 deletions

View file

@ -624,6 +624,9 @@ export async function bootstrapAutoSession(
s.consecutiveCompleteBootstraps = 0;
// ── Initialize session state ──
// Notify shared phase state so subagent conflict checks can fire
const { activateGSD: activateGSDPhaseState } = await import("../shared/gsd-phase-state.js");
activateGSDPhaseState();
s.active = true;
s.stepMode = requestedStepMode;
s.verbose = verboseMode;

View file

@ -115,6 +115,7 @@ import {
resetSkillTelemetry,
} from "./skill-telemetry.js";
import { getRtkSessionSavings } from "../shared/rtk-session-stats.js";
import { deactivateGSD } from "../shared/gsd-phase-state.js";
import {
initMetrics,
resetMetrics,
@ -622,6 +623,7 @@ function handleLostSessionLock(
});
s.active = false;
s.paused = false;
deactivateGSD();
clearUnitTimeout();
restoreProjectRootEnv();
restoreMilestoneLockEnv();
@ -659,6 +661,7 @@ function handleLostSessionLock(
function cleanupAfterLoopExit(ctx: ExtensionContext): void {
s.currentUnit = null;
s.active = false;
deactivateGSD();
clearUnitTimeout();
restoreProjectRootEnv();
restoreMilestoneLockEnv();
@ -1024,6 +1027,7 @@ export async function pauseAuto(
s.active = false;
s.paused = true;
deactivateGSD();
restoreProjectRootEnv();
restoreMilestoneLockEnv();
s.pendingVerificationRetry = null;

View file

@ -27,6 +27,7 @@ import { runUnit } from "./run-unit.js";
import { debugLog } from "../debug-logger.js";
import { PROJECT_FILES } from "../detection.js";
import { MergeConflictError } from "../git-service.js";
import { setCurrentPhase, clearCurrentPhase } from "../../shared/gsd-phase-state.js";
import { join, basename, dirname, parse as parsePath } from "node:path";
import { existsSync, cpSync, readdirSync } from "node:fs";
import { logWarning, logError } from "../workflow-logger.js";
@ -1068,6 +1069,7 @@ export async function runUnitPhase(
const previousTier = s.currentUnitRouting?.tier;
s.currentUnit = { type: unitType, id: unitId, startedAt: Date.now() };
setCurrentPhase(unitType);
s.lastToolInvocationError = null; // #2883: clear stale error from previous unit
const unitStartSeq = ic.nextSeq();
deps.emitJournalEvent({ ts: new Date().toISOString(), flowId: ic.flowId, seq: unitStartSeq, eventType: "unit-start", data: { unitType, unitId } });
@ -1529,6 +1531,7 @@ export async function runFinalize(
// Detach session from the timed-out unit so late async completions
// cannot mutate state for the next unit (#3757).
s.currentUnit = null;
clearCurrentPhase();
loopState.consecutiveFinalizeTimeouts++;
debugLog("autoLoop", {
phase: "pre-verification-timeout",
@ -1626,6 +1629,7 @@ export async function runFinalize(
// Detach session from the timed-out unit so late async completions
// cannot mutate state for the next unit (#3757).
s.currentUnit = null;
clearCurrentPhase();
loopState.consecutiveFinalizeTimeouts++;
debugLog("autoLoop", {
phase: "post-verification-timeout",

View file

@ -0,0 +1,42 @@
/**
* GSD Phase State cross-extension coordination
* Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
*
* Lightweight module-level state that GSD auto-mode writes to and the
* subagent tool reads from. Both extensions run in the same process so
* a module variable is sufficient no file I/O needed.
*/
let _active = false;
let _currentPhase: string | null = null;
/** Mark GSD auto-mode as active. */
export function activateGSD(): void {
_active = true;
}
/** Mark GSD auto-mode as inactive and clear the current phase. */
export function deactivateGSD(): void {
_active = false;
_currentPhase = null;
}
/** Set the currently dispatched GSD phase (e.g. "plan-milestone"). */
export function setCurrentPhase(phase: string): void {
_currentPhase = phase;
}
/** Clear the current phase (unit completed or aborted). */
export function clearCurrentPhase(): void {
_currentPhase = null;
}
/** Returns true if GSD auto-mode is currently active. */
export function isGSDActive(): boolean {
return _active;
}
/** Returns the current GSD phase, or null if none is active. */
export function getCurrentPhase(): string | null {
return _active ? _currentPhase : null;
}

View file

@ -0,0 +1,48 @@
import { describe, it, beforeEach } from "node:test";
import assert from "node:assert/strict";
import {
activateGSD,
deactivateGSD,
setCurrentPhase,
clearCurrentPhase,
isGSDActive,
getCurrentPhase,
} from "../gsd-phase-state.js";
describe("gsd-phase-state", () => {
beforeEach(() => {
deactivateGSD();
});
it("tracks active/inactive state", () => {
assert.equal(isGSDActive(), false);
activateGSD();
assert.equal(isGSDActive(), true);
deactivateGSD();
assert.equal(isGSDActive(), false);
});
it("tracks the current phase when active", () => {
activateGSD();
assert.equal(getCurrentPhase(), null);
setCurrentPhase("plan-milestone");
assert.equal(getCurrentPhase(), "plan-milestone");
clearCurrentPhase();
assert.equal(getCurrentPhase(), null);
});
it("returns null phase when inactive even if phase was set", () => {
activateGSD();
setCurrentPhase("plan-milestone");
deactivateGSD();
assert.equal(getCurrentPhase(), null);
});
it("deactivation clears the current phase", () => {
activateGSD();
setCurrentPhase("execute-task");
deactivateGSD();
activateGSD();
assert.equal(getCurrentPhase(), null);
});
});

View file

@ -15,6 +15,7 @@ export interface AgentConfig {
description: string;
tools?: string[];
model?: string;
conflictsWith?: string[];
systemPrompt: string;
source: "user" | "project";
filePath: string;
@ -30,6 +31,13 @@ interface AgentFrontmatter extends Record<string, unknown> {
description?: string;
tools?: string | string[];
model?: string;
conflicts_with?: string;
}
export function parseConflictsWith(value: string | undefined): string[] | undefined {
if (typeof value !== "string") return undefined;
const conflicts = value.split(",").map((s) => s.trim()).filter(Boolean);
return conflicts.length > 0 ? conflicts : undefined;
}
function parseAgentTools(value: string | string[] | undefined): string[] | undefined {
@ -85,12 +93,14 @@ function loadAgentsFromDir(dir: string, source: "user" | "project"): AgentConfig
}
const tools = parseAgentTools(frontmatter.tools);
const conflictsWith = parseConflictsWith(frontmatter.conflicts_with);
agents.push({
name: frontmatter.name,
description: frontmatter.description,
tools: tools && tools.length > 0 ? tools : undefined,
model: frontmatter.model,
conflictsWith,
systemPrompt: body,
source,
filePath,

View file

@ -24,6 +24,7 @@ import { type ExtensionAPI, getMarkdownTheme } from "@gsd/pi-coding-agent";
import { Container, Markdown, Spacer, Text } from "@gsd/pi-tui";
import { Type } from "@sinclair/typebox";
import { formatTokenCount } from "../shared/mod.js";
import { getCurrentPhase } from "../shared/gsd-phase-state.js";
import { type AgentConfig, type AgentScope, discoverAgents } from "./agents.js";
import {
type IsolationEnvironment,
@ -352,6 +353,23 @@ async function runSingleAgent(
};
}
// GSD phase guard: block agents that conflict with the active GSD phase
if (agent.conflictsWith && agent.conflictsWith.length > 0) {
const activePhase = getCurrentPhase();
if (activePhase && agent.conflictsWith.includes(activePhase)) {
return {
agent: agentName,
agentSource: agent.source,
task,
exitCode: 1,
messages: [],
stderr: `Agent "${agentName}" is blocked: it conflicts with the active GSD phase "${activePhase}". Use the built-in GSD workflow instead.`,
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
step,
};
}
}
let tmpPromptDir: string | null = null;
let tmpPromptPath: string | null = null;

View file

@ -0,0 +1,33 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { parseConflictsWith } from "../agents.js";
describe("parseConflictsWith", () => {
it("parses comma-separated conflict list", () => {
const result = parseConflictsWith("plan-milestone, plan-slice, research-milestone");
assert.deepEqual(result, ["plan-milestone", "plan-slice", "research-milestone"]);
});
it("returns undefined for undefined input", () => {
assert.equal(parseConflictsWith(undefined), undefined);
});
it("returns undefined for empty string", () => {
assert.equal(parseConflictsWith(""), undefined);
});
it("handles single value without commas", () => {
const result = parseConflictsWith("plan-milestone");
assert.deepEqual(result, ["plan-milestone"]);
});
it("trims whitespace from values", () => {
const result = parseConflictsWith(" plan-milestone , plan-slice ");
assert.deepEqual(result, ["plan-milestone", "plan-slice"]);
});
it("filters out empty entries from trailing commas", () => {
const result = parseConflictsWith("plan-milestone,,plan-slice,");
assert.deepEqual(result, ["plan-milestone", "plan-slice"]);
});
});