feat(gsd-uok): add turn-level git transaction modes and closeout gates

This commit is contained in:
Jeremy McSpadden 2026-04-14 20:41:26 -05:00
parent a2cc151bc9
commit d6c93ef07f
9 changed files with 422 additions and 14 deletions

View file

@ -29,9 +29,10 @@ import { rebuildState } from "./doctor.js";
import { parseUnitId } from "./unit-id.js";
import { closeoutUnit, type CloseoutOptions } from "./auto-unit-closeout.js";
import {
autoCommitCurrentBranch,
runTurnGitAction,
type TaskCommitContext,
} from "./worktree.js";
type TurnGitActionMode,
} from "./git-service.js";
import {
verifyExpectedArtifact,
resolveExpectedArtifactPath,
@ -68,6 +69,7 @@ import { writePreExecutionEvidence } from "./verification-evidence.js";
import { ensureCodebaseMapFresh } from "./codebase-generator.js";
import { resolveUokFlags } from "./uok/flags.js";
import { UokGateRunner } from "./uok/gate-runner.js";
import { writeTurnGitTransaction } from "./uok/gitops.js";
/** Maximum verification retry attempts before escalating to blocker placeholder (#2653). */
const MAX_VERIFICATION_RETRIES = 3;
@ -359,10 +361,161 @@ export async function postUnitPreVerification(pctx: PostUnitContext, opts?: PreV
await new Promise(r => setTimeout(r, 100));
}
// Auto-commit
const prefs = loadEffectiveGSDPreferences()?.preferences;
const uokFlags = resolveUokFlags(prefs);
// Turn-level git action (commit | snapshot | status-only)
if (s.currentUnit) {
const unit = s.currentUnit;
await autoCommitUnit(s.basePath, unit.type, unit.id, ctx);
const turnAction: TurnGitActionMode = uokFlags.gitops ? uokFlags.gitopsTurnAction : "commit";
const traceId = s.currentTraceId ?? `turn:${unit.startedAt}`;
const turnId = s.currentTurnId ?? `${unit.type}/${unit.id}/${unit.startedAt}`;
s.lastGitActionFailure = null;
s.lastGitActionStatus = null;
try {
let taskContext: TaskCommitContext | undefined;
if (turnAction === "commit" && s.currentUnit.type === "execute-task") {
const { milestone: mid, slice: sid, task: tid } = parseUnitId(s.currentUnit.id);
if (mid && sid && tid) {
const summaryPath = resolveTaskFile(s.basePath, mid, sid, tid, "SUMMARY");
if (summaryPath) {
try {
const summaryContent = await loadFile(summaryPath);
if (summaryContent) {
const summary = parseSummary(summaryContent);
// Look up GitHub issue number for commit linking
let ghIssueNumber: number | undefined;
try {
const { getTaskIssueNumberForCommit } = await import("../github-sync/sync.js");
ghIssueNumber = getTaskIssueNumberForCommit(s.basePath, mid, sid, tid) ?? undefined;
} catch (err) {
// GitHub sync not available — skip
logWarning("engine", `GitHub issue lookup failed: ${err instanceof Error ? err.message : String(err)}`);
}
taskContext = {
taskId: `${sid}/${tid}`,
taskTitle: summary.title?.replace(/^T\d+:\s*/, "") || tid,
oneLiner: summary.oneLiner || undefined,
keyFiles: summary.frontmatter.key_files?.filter(f => !f.includes("{{")) || undefined,
issueNumber: ghIssueNumber,
};
}
} catch (e) {
debugLog("postUnit", { phase: "task-summary-parse", error: String(e) });
}
}
}
}
// Invalidate the nativeHasChanges cache before auto-commit (#1853).
// The cache has a 10-second TTL and is keyed by basePath. A stale
// `false` result causes autoCommit to skip staging entirely, leaving
// code files only in the working tree where they are destroyed by
// `git worktree remove --force` during teardown.
_resetHasChangesCache();
const skipLifecycleCommit =
turnAction === "commit" && LIFECYCLE_ONLY_UNITS.has(s.currentUnit.type);
if (skipLifecycleCommit) {
debugLog("postUnit", {
phase: "git-action-skipped",
reason: "lifecycle-only-unit",
unitType: s.currentUnit.type,
unitId: s.currentUnit.id,
});
} else {
const gitResult = runTurnGitAction({
basePath: s.basePath,
action: turnAction,
unitType: s.currentUnit.type,
unitId: s.currentUnit.id,
taskContext,
});
if (uokFlags.gitops) {
writeTurnGitTransaction({
basePath: s.basePath,
traceId,
turnId,
unitType: unit.type,
unitId: unit.id,
stage: "publish",
action: turnAction,
push: uokFlags.gitopsTurnPush,
status: gitResult.status,
error: gitResult.error,
metadata: {
dirty: gitResult.dirty,
commitMessage: gitResult.commitMessage,
snapshotLabel: gitResult.snapshotLabel,
},
});
}
if (gitResult.status === "failed") {
s.lastGitActionFailure = gitResult.error ?? `git ${turnAction} failed`;
s.lastGitActionStatus = "failed";
if (uokFlags.gitops && uokFlags.gates) {
const parsed = parseUnitId(unit.id);
const gateRunner = new UokGateRunner();
gateRunner.register({
id: "closeout-git-action",
type: "closeout",
execute: async () => ({
outcome: "fail",
failureClass: "git",
rationale: `turn git action "${turnAction}" failed`,
findings: gitResult.error ?? "unknown git failure",
}),
});
await gateRunner.run("closeout-git-action", {
basePath: s.basePath,
traceId,
turnId,
milestoneId: parsed.milestone ?? undefined,
sliceId: parsed.slice ?? undefined,
taskId: parsed.task ?? undefined,
unitType: unit.type,
unitId: unit.id,
});
}
const failureMsg = `Git ${turnAction} failed: ${(gitResult.error ?? "unknown error").split("\n")[0]}`;
if (uokFlags.gitops) {
ctx.ui.notify(failureMsg, "error");
await pauseAuto(ctx, pi);
return "dispatched";
}
ctx.ui.notify(failureMsg, "warning");
debugLog("postUnit", {
phase: "git-action-failed-nonblocking",
action: turnAction,
error: gitResult.error ?? "unknown error",
});
}
s.lastGitActionStatus = "ok";
if (turnAction === "commit" && gitResult.commitMessage) {
ctx.ui.notify(`Committed: ${gitResult.commitMessage.split("\n")[0]}`, "info");
} else if (turnAction === "snapshot" && gitResult.snapshotLabel) {
ctx.ui.notify(`Snapshot recorded: ${gitResult.snapshotLabel}`, "info");
}
}
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
s.lastGitActionFailure = message;
s.lastGitActionStatus = "failed";
debugLog("postUnit", { phase: "git-action", error: message, action: turnAction });
ctx.ui.notify(`Git ${turnAction} failed: ${message.split("\n")[0]}`, uokFlags.gitops ? "error" : "warning");
if (uokFlags.gitops) {
await pauseAuto(ctx, pi);
return "dispatched";
}
}
// GitHub sync (non-blocking, opt-in)
await runSafely("postUnit", "github-sync", async () => {
@ -871,6 +1024,7 @@ export async function postUnitPostVerification(pctx: PostUnitContext): Promise<"
s.currentUnit &&
s.currentUnit.type === "plan-slice"
) {
const currentUnit = s.currentUnit;
let preExecPauseNeeded = false;
await runSafely("postUnitPostVerification", "pre-execution-checks", async () => {
const prefs = loadEffectiveGSDPreferences()?.preferences;
@ -890,7 +1044,7 @@ export async function postUnitPostVerification(pctx: PostUnitContext): Promise<"
}
// Parse the unit ID to get milestone/slice IDs
const { milestone: mid, slice: sid } = parseUnitId(s.currentUnit!.id);
const { milestone: mid, slice: sid } = parseUnitId(currentUnit.id);
if (!mid || !sid) {
debugLog("postUnitPostVerification", {
phase: "pre-execution-checks",
@ -957,12 +1111,12 @@ export async function postUnitPostVerification(pctx: PostUnitContext): Promise<"
});
await gateRunner.run("pre-execution-checks", {
basePath: s.basePath,
traceId: `pre-execution:${s.currentUnit.id}`,
turnId: s.currentUnit.id,
traceId: `pre-execution:${currentUnit.id}`,
turnId: currentUnit.id,
milestoneId: mid,
sliceId: sid,
unitType: s.currentUnit.type,
unitId: s.currentUnit.id,
unitType: currentUnit.type,
unitId: currentUnit.id,
});
}

View file

@ -8,6 +8,7 @@ import type { ExtensionContext } from "@gsd/pi-coding-agent";
import { snapshotUnitMetrics } from "./metrics.js";
import { saveActivityLog } from "./activity-log.js";
import { logWarning } from "./workflow-logger.js";
import { writeTurnGitTransaction } from "./uok/gitops.js";
export interface CloseoutOptions {
promptCharCount?: number;
@ -15,6 +16,12 @@ export interface CloseoutOptions {
tier?: string;
modelDowngraded?: boolean;
continueHereFired?: boolean;
traceId?: string;
turnId?: string;
gitAction?: "commit" | "snapshot" | "status-only";
gitPush?: boolean;
gitStatus?: "ok" | "failed";
gitError?: string;
}
/**
@ -47,6 +54,23 @@ export async function closeoutUnit(
}
}
if (opts?.traceId && opts.turnId && opts.gitAction && opts.gitStatus) {
writeTurnGitTransaction({
basePath,
traceId: opts.traceId,
turnId: opts.turnId,
unitType,
unitId,
stage: "record",
action: opts.gitAction,
push: opts.gitPush === true,
status: opts.gitStatus,
error: opts.gitError,
metadata: {
activityFile,
},
});
}
return activityFile ?? undefined;
}

View file

@ -203,6 +203,7 @@ import { bootstrapAutoSession, openProjectDbIfPresent, type BootstrapDeps } from
import { initHealthWidget } from "./health-widget.js";
import { autoLoop, resolveAgentEnd, resolveAgentEndCancelled, _resetPendingResolve, isSessionSwitchInFlight, type LoopDeps, type ErrorContext } from "./auto-loop.js";
import { runAutoLoopWithUok } from "./uok/kernel.js";
import { resolveUokFlags } from "./uok/flags.js";
// Slice-level parallelism (#2340)
import { getEligibleSlices } from "./slice-parallel-eligibility.js";
import { startSliceParallel } from "./slice-parallel-orchestrator.js";
@ -606,11 +607,29 @@ function buildSnapshotOpts(
continueHereFired?: boolean;
promptCharCount?: number;
baselineCharCount?: number;
traceId?: string;
turnId?: string;
gitAction?: "commit" | "snapshot" | "status-only";
gitPush?: boolean;
gitStatus?: "ok" | "failed";
gitError?: string;
} & Record<string, unknown> {
const prefs = loadEffectiveGSDPreferences()?.preferences;
const uokFlags = resolveUokFlags(prefs);
return {
...(s.autoStartTime > 0 ? { autoSessionKey: String(s.autoStartTime) } : {}),
promptCharCount: s.lastPromptCharCount,
baselineCharCount: s.lastBaselineCharCount,
traceId: s.currentTraceId ?? undefined,
turnId: s.currentTurnId ?? undefined,
...(uokFlags.gitops
? {
gitAction: uokFlags.gitopsTurnAction,
gitPush: uokFlags.gitopsTurnPush,
gitStatus: s.lastGitActionStatus ?? undefined,
gitError: s.lastGitActionFailure ?? undefined,
}
: {}),
...(s.currentUnitRouting ?? {}),
};
}

View file

@ -131,13 +131,15 @@ export async function autoLoop(
let seqCounter = 0;
const nextSeq = () => ++seqCounter;
const turnId = randomUUID();
s.currentTraceId = flowId;
s.currentTurnId = turnId;
const turnStartedAt = new Date().toISOString();
let observedUnitType: string | undefined;
let observedUnitId: string | undefined;
let turnFinished = false;
const finishTurn = (
status: "completed" | "failed" | "paused" | "stopped" | "skipped" | "retry",
failureClass: "none" | "unknown" | "manual-attention" | "timeout" | "execution" | "closeout" = "none",
failureClass: "none" | "unknown" | "manual-attention" | "timeout" | "execution" | "closeout" | "git" = "none",
error?: string,
): void => {
if (turnFinished) return;
@ -155,6 +157,8 @@ export async function autoLoop(
startedAt: turnStartedAt,
finishedAt: new Date().toISOString(),
});
s.currentTraceId = null;
s.currentTurnId = null;
};
deps.uokObserver?.onTurnStart({
traceId: flowId,
@ -483,7 +487,10 @@ export async function autoLoop(
unitId: iterData.unitId,
});
if (finalizeResult.action === "break") {
finishTurn("stopped", "closeout", "finalize-break");
const finalizeFailureClass = finalizeResult.reason === "git-closeout-failure"
? "git"
: "closeout";
finishTurn("stopped", finalizeFailureClass, "finalize-break");
break;
}
if (finalizeResult.action === "continue") {

View file

@ -1219,6 +1219,8 @@ export async function runUnitPhase(
// unit in the same Node process (see workflow-logger.ts module header).
_resetLogs();
s.currentUnit = { type: unitType, id: unitId, startedAt: Date.now() };
s.lastGitActionFailure = null;
s.lastGitActionStatus = null;
setCurrentPhase(unitType);
s.lastToolInvocationError = null; // #2883: clear stale error from previous unit
const unitStartSeq = ic.nextSeq();
@ -1727,11 +1729,15 @@ export async function runFinalize(
const preResult = preResultGuard.value;
if (preResult === "dispatched") {
const dispatchedReason = s.lastGitActionFailure
? "git-closeout-failure"
: "pre-verification-dispatched";
debugLog("autoLoop", {
phase: "exit",
reason: "pre-verification-dispatched",
reason: dispatchedReason,
gitError: s.lastGitActionFailure ?? undefined,
});
return { action: "break", reason: "pre-verification-dispatched" };
return { action: "break", reason: dispatchedReason };
}
if (preResult === "retry") {
if (sidecarItem) {

View file

@ -106,6 +106,8 @@ export class AutoSession {
// ── Current unit ─────────────────────────────────────────────────────────
currentUnit: CurrentUnit | null = null;
currentTraceId: string | null = null;
currentTurnId: string | null = null;
currentUnitRouting: UnitRouting | null = null;
currentMilestoneId: string | null = null;
@ -137,6 +139,10 @@ export class AutoSession {
/** Set when a GSD tool execution ends with isError due to malformed/truncated
* JSON arguments. Checked by postUnitPreVerification to break retry loops. */
lastToolInvocationError: string | null = null;
/** Set when turn-level git action fails during closeout. */
lastGitActionFailure: string | null = null;
/** Last turn-level git action status captured during finalize. */
lastGitActionStatus: "ok" | "failed" | null = null;
// ── Isolation degradation ────────────────────────────────────────────
/** Set to true when worktree creation fails; prevents merge of nonexistent branch. */
@ -219,6 +225,8 @@ export class AutoSession {
// Unit
this.currentUnit = null;
this.currentTraceId = null;
this.currentTurnId = null;
this.currentUnitRouting = null;
this.currentMilestoneId = null;
@ -250,6 +258,8 @@ export class AutoSession {
this.rewriteAttemptCount = 0;
this.consecutiveCompleteBootstraps = 0;
this.lastToolInvocationError = null;
this.lastGitActionFailure = null;
this.lastGitActionStatus = null;
this.isolationDegraded = false;
this.milestoneMergedInPhases = false;
this.checkpointSha = null;

View file

@ -34,6 +34,7 @@ import {
nativeAddPaths,
nativeResetSoft,
nativeCommitSubject,
_resetHasChangesCache,
} from "./native-git-bridge.js";
import { GSDError, GSD_MERGE_CONFLICT, GSD_GIT_ERROR } from "./errors.js";
import { getErrorMessage } from "./error-utils.js";
@ -93,6 +94,17 @@ export interface CommitOptions {
allowEmpty?: boolean;
}
export type TurnGitActionMode = "commit" | "snapshot" | "status-only";
export interface TurnGitActionResult {
action: TurnGitActionMode;
status: "ok" | "failed";
commitMessage?: string;
snapshotLabel?: string;
dirty?: boolean;
error?: string;
}
// ─── Meaningful Commit Message Generation ───────────────────────────────────
/** Context for generating a meaningful commit message from task execution results. */
@ -822,6 +834,62 @@ export function createGitService(basePath: string): GitServiceImpl {
return new GitServiceImpl(basePath, gitPrefs);
}
function buildTurnSnapshotLabel(unitType: string, unitId: string): string {
const raw = `${unitType}/${unitId}`.trim();
if (!raw) return "turn";
return raw
.replace(/[^a-zA-Z0-9._/-]/g, "-")
.replace(/\/{2,}/g, "/")
.replace(/-{2,}/g, "-")
.replace(/^[-/]+|[-/]+$/g, "") || "turn";
}
export function runTurnGitAction(args: {
basePath: string;
action: TurnGitActionMode;
unitType: string;
unitId: string;
taskContext?: TaskCommitContext;
}): TurnGitActionResult {
try {
// Force fresh working-tree status per turn; nativeHasChanges caches briefly.
_resetHasChangesCache();
if (args.action === "status-only") {
return {
action: args.action,
status: "ok",
dirty: nativeHasChanges(args.basePath),
};
}
const git = createGitService(args.basePath);
if (args.action === "snapshot") {
const label = buildTurnSnapshotLabel(args.unitType, args.unitId);
git.createSnapshot(label);
return {
action: args.action,
status: "ok",
snapshotLabel: label,
dirty: nativeHasChanges(args.basePath),
};
}
const commitMessage = git.autoCommit(args.unitType, args.unitId, [], args.taskContext) ?? undefined;
return {
action: args.action,
status: "ok",
commitMessage,
dirty: nativeHasChanges(args.basePath),
};
} catch (err) {
return {
action: args.action,
status: "failed",
error: getErrorMessage(err),
};
}
}
// ─── Commit Type Inference ─────────────────────────────────────────────────
/**

View file

@ -0,0 +1,85 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { execSync } from "node:child_process";
import { runTurnGitAction } from "../git-service.ts";
function run(cmd: string, cwd: string): string {
return execSync(cmd, { cwd, stdio: "pipe", encoding: "utf-8" }).trim();
}
function makeRepo(): string {
const repo = mkdtempSync(join(tmpdir(), "gsd-uok-gitops-"));
run("git init", repo);
run('git config user.email "test@example.com"', repo);
run('git config user.name "Test User"', repo);
writeFileSync(join(repo, "README.md"), "# Test\n", "utf-8");
run("git add README.md", repo);
run('git commit -m "chore: init"', repo);
return repo;
}
test("uok gitops turn action status-only reports working tree dirtiness", () => {
const repo = makeRepo();
try {
const clean = runTurnGitAction({
basePath: repo,
action: "status-only",
unitType: "execute-task",
unitId: "M001/S01/T01",
});
assert.equal(clean.status, "ok");
assert.equal(clean.dirty, false);
writeFileSync(join(repo, "README.md"), "# Dirty\n", "utf-8");
const dirty = runTurnGitAction({
basePath: repo,
action: "status-only",
unitType: "execute-task",
unitId: "M001/S01/T01",
});
assert.equal(dirty.status, "ok");
assert.equal(dirty.dirty, true);
} finally {
rmSync(repo, { recursive: true, force: true });
}
});
test("uok gitops turn action snapshot writes snapshot refs", () => {
const repo = makeRepo();
try {
const result = runTurnGitAction({
basePath: repo,
action: "snapshot",
unitType: "execute-task",
unitId: "M001/S01/T01",
});
assert.equal(result.status, "ok");
assert.ok(result.snapshotLabel?.includes("execute-task/M001/S01/T01"));
const refs = run("git for-each-ref refs/gsd/snapshots/ --format='%(refname)'", repo);
assert.ok(refs.includes("refs/gsd/snapshots/execute-task/M001/S01/T01/"));
} finally {
rmSync(repo, { recursive: true, force: true });
}
});
test("uok gitops turn action commit creates commit with unit trailer", () => {
const repo = makeRepo();
try {
writeFileSync(join(repo, "feature.ts"), "export const x = 1;\n", "utf-8");
const result = runTurnGitAction({
basePath: repo,
action: "commit",
unitType: "execute-task",
unitId: "M001/S01/T02",
});
assert.equal(result.status, "ok");
assert.ok(result.commitMessage?.includes("chore: auto-commit after execute-task"));
const body = run("git log -1 --pretty=%B", repo);
assert.ok(body.includes("GSD-Unit: M001/S01/T02"));
} finally {
rmSync(repo, { recursive: true, force: true });
}
});

View file

@ -0,0 +1,35 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const gsdDir = join(__dirname, "..");
test("post-unit pre-verification selects turn git action from UOK gitops flags", () => {
const source = readFileSync(join(gsdDir, "auto-post-unit.ts"), "utf-8");
assert.ok(
source.includes("const turnAction: TurnGitActionMode = uokFlags.gitops ? uokFlags.gitopsTurnAction : \"commit\""),
"postUnitPreVerification should derive turn action from uok.gitops.turn_action when enabled",
);
});
test("post-unit pre-verification routes git failures through closeout gate", () => {
const source = readFileSync(join(gsdDir, "auto-post-unit.ts"), "utf-8");
assert.ok(
source.includes('id: "closeout-git-action"') &&
source.includes('type: "closeout"') &&
source.includes('failureClass: "git"'),
"git failures should be persisted via a closeout gate with failureClass=git",
);
});
test("auto snapshot opts carry trace/turn IDs for turn closeout records", () => {
const source = readFileSync(join(gsdDir, "auto.ts"), "utf-8");
assert.ok(
source.includes("traceId: s.currentTraceId ?? undefined") &&
source.includes("turnId: s.currentTurnId ?? undefined"),
"buildSnapshotOpts should pass trace/turn IDs into closeout options",
);
});