diff --git a/src/resources/extensions/gsd/auto-post-unit.ts b/src/resources/extensions/gsd/auto-post-unit.ts index a2b714c86..cddb84120 100644 --- a/src/resources/extensions/gsd/auto-post-unit.ts +++ b/src/resources/extensions/gsd/auto-post-unit.ts @@ -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, }); } diff --git a/src/resources/extensions/gsd/auto-unit-closeout.ts b/src/resources/extensions/gsd/auto-unit-closeout.ts index ccd274176..45d8dce78 100644 --- a/src/resources/extensions/gsd/auto-unit-closeout.ts +++ b/src/resources/extensions/gsd/auto-unit-closeout.ts @@ -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; } - diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index 6cc197b1c..e39653e15 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -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 { + 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 ?? {}), }; } diff --git a/src/resources/extensions/gsd/auto/loop.ts b/src/resources/extensions/gsd/auto/loop.ts index b2c273254..a35f8d672 100644 --- a/src/resources/extensions/gsd/auto/loop.ts +++ b/src/resources/extensions/gsd/auto/loop.ts @@ -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") { diff --git a/src/resources/extensions/gsd/auto/phases.ts b/src/resources/extensions/gsd/auto/phases.ts index 647cafa90..7f3067778 100644 --- a/src/resources/extensions/gsd/auto/phases.ts +++ b/src/resources/extensions/gsd/auto/phases.ts @@ -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) { diff --git a/src/resources/extensions/gsd/auto/session.ts b/src/resources/extensions/gsd/auto/session.ts index 426713411..fd2d6a9d1 100644 --- a/src/resources/extensions/gsd/auto/session.ts +++ b/src/resources/extensions/gsd/auto/session.ts @@ -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; diff --git a/src/resources/extensions/gsd/git-service.ts b/src/resources/extensions/gsd/git-service.ts index c937da714..bc1698505 100644 --- a/src/resources/extensions/gsd/git-service.ts +++ b/src/resources/extensions/gsd/git-service.ts @@ -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 ───────────────────────────────────────────────── /** diff --git a/src/resources/extensions/gsd/tests/uok-gitops-turn-action.test.ts b/src/resources/extensions/gsd/tests/uok-gitops-turn-action.test.ts new file mode 100644 index 000000000..0e2a6fc83 --- /dev/null +++ b/src/resources/extensions/gsd/tests/uok-gitops-turn-action.test.ts @@ -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 }); + } +}); diff --git a/src/resources/extensions/gsd/tests/uok-gitops-wiring.test.ts b/src/resources/extensions/gsd/tests/uok-gitops-wiring.test.ts new file mode 100644 index 000000000..80c9dafd2 --- /dev/null +++ b/src/resources/extensions/gsd/tests/uok-gitops-wiring.test.ts @@ -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", + ); +});