Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions packages/gittensory-engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,45 @@ export {
type GovernorLedgerEventType,
type NormalizedGovernorLedgerEvent,
} from "./governor-ledger.js";
export {
ATTEMPT_LOG_EVENT_TYPES,
createAttemptLogBuffer,
formatAttemptLogJsonl,
normalizeAttemptLogEvent,
type AttemptLogEvent,
type AttemptLogEventType,
type NormalizedAttemptLogEvent,
} from "./miner/attempt-log.js";
export {
codingAgentModeExecutes,
isGlobalMinerCodingAgentPause,
resolveCodingAgentExecutionMode,
resolveCodingAgentModeFromConfig,
type CodingAgentExecutionMode,
} from "./miner/coding-agent-mode.js";
export {
createFakeCodingAgentDriver,
createNoopCodingAgentDriver,
type CodingAgentDriver,
type CodingAgentDriverResult,
type CodingAgentDriverTask,
} from "./miner/coding-agent-driver.js";
export {
invokeCodingAgentDriver,
type AttemptLogSink,
} from "./miner/coding-agent-invoke.js";
export {
CODING_AGENT_DRIVER_CONFIG_ENV,
CODING_AGENT_DRIVER_NAMES,
createCodingAgentDriver,
createFakeCodingAgentDriverForFactory,
isConfiguredCodingAgentDriver,
resolveConfiguredCodingAgentDriverNames,
runCodingAgentAttempt,
type CodingAgentDriverName,
type CreateCodingAgentDriverOptions,
type RunCodingAgentAttemptOptions,
} from "./miner/driver-factory.js";
export * from "./plan-export.js";
export { countPlanStepsByStatus } from "./plan-step-stats.js";
export { countPlanSteps } from "./plan-step-count.js";
Expand Down
123 changes: 123 additions & 0 deletions packages/gittensory-engine/src/miner/attempt-log.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
// Driver-level structured attempt log — pure event shapes (#4294). Mirrors `governor-ledger.ts`: fixed vocabulary,
// fail-closed normalization, JSON-round-trip-verified payloads. Persistence (SQLite/JSONL export) is a miner-package
// follow-up; this module is the engine-side contract the invoke layer writes into.

import type { CodingAgentExecutionMode } from "./coding-agent-mode.js";

export const ATTEMPT_LOG_EVENT_TYPES = Object.freeze([
"attempt_started",
"attempt_shadow",
"attempt_succeeded",
"attempt_failed",
"attempt_aborted",
] as const);

export type AttemptLogEventType = (typeof ATTEMPT_LOG_EVENT_TYPES)[number];

export type AttemptLogEvent = {
eventType: AttemptLogEventType;
attemptId: string;
actionClass: string;
mode: CodingAgentExecutionMode;
reason: string;
payload?: Record<string, unknown> | undefined;
};

export type NormalizedAttemptLogEvent = {
eventType: AttemptLogEventType;
attemptId: string;
actionClass: string;
mode: CodingAgentExecutionMode;
reason: string;
payloadJson: string;
};

const attemptEventTypeSet = new Set<string>(ATTEMPT_LOG_EVENT_TYPES);
const codingAgentModes = new Set<string>(["paused", "dry_run", "live"]);

/* v8 ignore start -- Normalization helpers are covered through normalizeAttemptLogEvent export tests. */
function normalizeRequiredString(value: unknown, code: string): string {
if (typeof value !== "string") throw new Error(code);
const trimmed = value.trim();
if (!trimmed) throw new Error(code);
return trimmed;
}

function jsonRoundTripEqual(a: unknown, b: unknown): boolean {
if (a === b) return true;
if (typeof a !== typeof b || a === null || b === null) return false;
if (typeof a !== "object") return false;
const aIsArray = Array.isArray(a);
if (aIsArray !== Array.isArray(b)) return false;
if (aIsArray) {
const bArr = b as unknown[];
const aArr = a as unknown[];
return aArr.length === bArr.length && aArr.every((value, index) => jsonRoundTripEqual(value, bArr[index]));
}
const aKeys = Object.keys(a as object);
const bRecord = b as Record<string, unknown>;
return aKeys.length === Object.keys(bRecord).length && aKeys.every((key) => Object.hasOwn(bRecord, key) && jsonRoundTripEqual((a as Record<string, unknown>)[key], bRecord[key]));
}

function serializePayload(payload: unknown): string {
if (payload === undefined) return "{}";
if (payload === null || typeof payload !== "object" || Array.isArray(payload)) {
throw new Error("invalid_payload");
}
let json: string;
try {
json = JSON.stringify(payload);
} catch {
throw new Error("invalid_payload");
}
if (!jsonRoundTripEqual(JSON.parse(json), payload)) {
throw new Error("invalid_payload");
}
return json;
}
/* v8 ignore stop */

function normalizeMode(value: unknown): CodingAgentExecutionMode {
const mode = normalizeRequiredString(value, "invalid_mode");
if (!codingAgentModes.has(mode)) throw new Error("invalid_mode");
return mode as CodingAgentExecutionMode;
}

/** Validate and normalize an attempt-log row before append. Fail-closed on unknown types/modes. */
export function normalizeAttemptLogEvent(input: unknown): NormalizedAttemptLogEvent {
if (!input || typeof input !== "object") throw new Error("invalid_event");
const event = input as Partial<AttemptLogEvent>;
const eventType = normalizeRequiredString(event.eventType, "invalid_event_type");
if (!attemptEventTypeSet.has(eventType)) throw new Error("invalid_event_type");
return {
eventType: eventType as AttemptLogEventType,
attemptId: normalizeRequiredString(event.attemptId, "invalid_attempt_id"),
actionClass: normalizeRequiredString(event.actionClass, "invalid_action_class"),
mode: normalizeMode(event.mode),
reason: normalizeRequiredString(event.reason, "invalid_reason"),
payloadJson: serializePayload(event.payload),
};
}

/** Serialize normalized events as JSONL (one attempt's trace). Pure. */
export function formatAttemptLogJsonl(events: readonly NormalizedAttemptLogEvent[]): string {
return events.map((event) => JSON.stringify(event)).join("\n");
}

/** In-memory appender for tests and local tooling — production persistence imports the normalizer only. */
export function createAttemptLogBuffer(): {
append: (event: AttemptLogEvent) => NormalizedAttemptLogEvent;
events: () => readonly NormalizedAttemptLogEvent[];
jsonl: () => string;
} {
const rows: NormalizedAttemptLogEvent[] = [];
return {
append(event) {
const normalized = normalizeAttemptLogEvent(event);
rows.push(normalized);
return normalized;
},
events: () => rows,
jsonl: () => formatAttemptLogJsonl(rows),
};
}
68 changes: 68 additions & 0 deletions packages/gittensory-engine/src/miner/coding-agent-driver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// `CodingAgentDriver` interface seam (#4262). Mirrors `SelfHostAi` (`src/selfhost/ai.ts:60-63`): a single `run()`
// method, provider-agnostic task/result types, and injectable deps on concrete implementations (spawn fn, clock,
// filesystem) rather than hardcoded globals. Implementations MAY perform real IO; this file defines only the
// contract — orchestration (mode gating, attempt logging, factory selection) lives in sibling miner modules.

/** Scoped local task handed to every driver implementation — no GitHub writes, no autonomous continue/stop. */
export type CodingAgentDriverTask = {
attemptId: string;
workingDirectory: string;
acceptanceCriteriaPath: string;
instructions: string;
maxTurns: number;
};

/** Provider-agnostic result — nothing here assumes a subprocess CLI vs. an Agent SDK `query()` loop. */
export type CodingAgentDriverResult = {
ok: boolean;
changedFiles: readonly string[];
summary: string;
/** Opaque provider transcript for operator inspection; absent when the driver did not run. */
transcript?: string | undefined;
turnsUsed?: number | undefined;
error?: string | undefined;
};

export interface CodingAgentDriver {
run(task: CodingAgentDriverTask): Promise<CodingAgentDriverResult>;
}

/** Minimal in-memory fake for contract/parity tests — records the last task without IO. */
export function createFakeCodingAgentDriver(
impl: Partial<{
run: CodingAgentDriver["run"];
lastTask: CodingAgentDriverTask | null;
}> = {},
): CodingAgentDriver & { lastTask: CodingAgentDriverTask | null } {
const state = { lastTask: impl.lastTask ?? null };
return {
get lastTask() {
return state.lastTask;
},
run:
impl.run ??
(async (task) => {
state.lastTask = task;
return {
ok: true,
changedFiles: [],
summary: `fake driver ran ${task.attemptId}`,
turnsUsed: 0,
};
}),
};
}

/** Default-OFF stub driver for factory resolution tests — never touches the filesystem. */
export function createNoopCodingAgentDriver(): CodingAgentDriver {
return {
async run(task) {
return {
ok: true,
changedFiles: [],
summary: `noop driver acknowledged ${task.attemptId}`,
turnsUsed: 0,
};
},
};
}
100 changes: 100 additions & 0 deletions packages/gittensory-engine/src/miner/coding-agent-invoke.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// Mode-gated `CodingAgentDriver` invocation (#4313). Single call site that applies `CodingAgentExecutionMode`,
// writes attempt-log events (#4294), and never spawns the underlying agent unless mode is `live`.

import type { CodingAgentDriver, CodingAgentDriverResult, CodingAgentDriverTask } from "./coding-agent-driver.js";
import {
codingAgentModeExecutes,
type CodingAgentExecutionMode,
} from "./coding-agent-mode.js";
import type { AttemptLogEvent } from "./attempt-log.js";

export type AttemptLogSink = {
append(event: AttemptLogEvent): void;
};

function shadowSummary(task: CodingAgentDriverTask): string {
return `dry-run: would invoke coding agent in ${task.workingDirectory} (≤${task.maxTurns} turns, criteria ${task.acceptanceCriteriaPath})`;
}

/**
* Invoke a driver under the resolved execution mode. `paused` and `dry_run` never call `driver.run()` — see
* `coding-agent-mode.ts` for the dry-run tradeoff documentation.
*/
export async function invokeCodingAgentDriver(
driver: CodingAgentDriver,
mode: CodingAgentExecutionMode,
task: CodingAgentDriverTask,
log?: AttemptLogSink | undefined,
): Promise<CodingAgentDriverResult> {
const base = { attemptId: task.attemptId, actionClass: "codegen", mode } as const;

if (mode === "paused") {
log?.append({
eventType: "attempt_aborted",
...base,
reason: "coding_agent_paused",
payload: { workingDirectory: task.workingDirectory },
});
return {
ok: false,
changedFiles: [],
summary: "coding agent paused",
error: "coding_agent_paused",
};
}

if (!codingAgentModeExecutes(mode)) {
log?.append({
eventType: "attempt_shadow",
...base,
reason: "dry-run: would invoke coding agent without spawning underlying session",
payload: {
workingDirectory: task.workingDirectory,
acceptanceCriteriaPath: task.acceptanceCriteriaPath,
maxTurns: task.maxTurns,
},
});
return {
ok: true,
changedFiles: [],
summary: shadowSummary(task),
turnsUsed: 0,
};
}

log?.append({
eventType: "attempt_started",
...base,
reason: "live coding-agent invocation",
payload: { workingDirectory: task.workingDirectory, maxTurns: task.maxTurns },
});

try {
const result = await driver.run(task);
log?.append({
eventType: result.ok ? "attempt_succeeded" : "attempt_failed",
...base,
reason: result.summary,
payload: {
changedFiles: [...result.changedFiles],
turnsUsed: result.turnsUsed ?? null,
error: result.error ?? null,
},
});
return result;
} catch (error) {
const message = error instanceof Error ? error.message : "unknown error";
log?.append({
eventType: "attempt_failed",
...base,
reason: message,
payload: { thrown: true },
});
return {
ok: false,
changedFiles: [],
summary: "coding agent invocation failed",
error: message,
};
}
}
58 changes: 58 additions & 0 deletions packages/gittensory-engine/src/miner/coding-agent-mode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// Coding-agent execution mode (#4313). Mirrors `AgentActionMode` at `src/settings/agent-execution.ts:23-48`:
// three states (`paused` | `dry_run` | `live`), deny-toward-safety precedence (global/per-repo pause beats
// dry-run beats live), and a single `codingAgentModeExecutes` boolean for callers.
//
// Why three states instead of a boolean? The maintainer-action layer already proved the shape: `paused` is an
// operator kill-switch (attempt never starts), `dry_run` is an observe/shadow path (record intent without the
// expensive/dangerous work), and `live` is the only mode that actually spawns/queries the underlying agent.
// A boolean would collapse `paused` into `dry_run`, losing the distinction between "halt entirely" and
// "shadow what would happen".
//
// Dry-run semantics for a CODING agent (#4313): at the driver invocation boundary, `dry_run` is a **pure no-op**
// — the underlying CLI/SDK session is never spawned. Tradeoff documented here:
// • Chosen: never spawn (cheapest, safest, mirrors `agentActionModeExecutes` skipping GitHub mutations).
// • Deferred alternative: run inside an isolated worktree (#4269) but suppress commit/push/PR downstream so
// file edits remain inspectable — requires the worktree primitive and orchestrator gating on create-phase
// steps; the attempt log records `attempt_shadow` with mode=`dry_run` so either path stays auditable.

/** Whether a coding-agent attempt actually spawns/queries the underlying session. */
export type CodingAgentExecutionMode = "paused" | "dry_run" | "live";

/** Global kill-switch for miner coding-agent invocations (`MINER_CODING_AGENT_PAUSED`). Same truthy-string
* convention as `isGlobalAgentPause` (`AGENT_ACTIONS_PAUSED`). */
export function isGlobalMinerCodingAgentPause(env: {
MINER_CODING_AGENT_PAUSED?: string | undefined;
}): boolean {
return /^(1|true|yes|on)$/i.test(env.MINER_CODING_AGENT_PAUSED ?? "");
}

/** THE single gate before invoking a `CodingAgentDriver`. Precedence (safest wins): global OR per-config pause
* → `paused`; else per-config dry-run → `dry_run`; else `live`. Pure. */
export function resolveCodingAgentExecutionMode(input: {
globalPaused: boolean;
agentPaused?: boolean | null | undefined;
agentDryRun?: boolean | null | undefined;
}): CodingAgentExecutionMode {
if (input.globalPaused || input.agentPaused === true) return "paused";
if (input.agentDryRun === true) return "dry_run";
return "live";
}

/** Resolve mode from env + optional per-run overrides (mirrors `resolveAgentActionMode` call sites). */
export function resolveCodingAgentModeFromConfig(config: {
env?: { MINER_CODING_AGENT_PAUSED?: string | undefined } | undefined;
agentPaused?: boolean | null | undefined;
agentDryRun?: boolean | null | undefined;
}): CodingAgentExecutionMode {
return resolveCodingAgentExecutionMode({
globalPaused: isGlobalMinerCodingAgentPause(config.env ?? {}),
agentPaused: config.agentPaused,
agentDryRun: config.agentDryRun,
});
}

/** True only for `live` — the only mode that performs a real driver `run()`. `paused` does nothing; `dry_run`
* records a shadow result without spawning the underlying agent. */
export function codingAgentModeExecutes(mode: CodingAgentExecutionMode): boolean {
return mode === "live";
}
Loading