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:
parent
66f0d45a8c
commit
0c19ca88f2
8 changed files with 162 additions and 0 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
42
src/resources/extensions/shared/gsd-phase-state.ts
Normal file
42
src/resources/extensions/shared/gsd-phase-state.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue