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
7 changes: 7 additions & 0 deletions packages/gittensory-miner/bin/gittensory-miner.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#!/usr/bin/env node
import { runAttempt } from "../lib/attempt-cli.js";
import { printHelp, printVersion, runCli } from "../lib/cli.js";
import { runDenyCheck } from "../lib/deny-check.js";
import { runDiscover } from "../lib/discover-cli.js";
Expand Down Expand Up @@ -121,6 +122,12 @@ if (cliArgs[0] === "discover") {
process.exit(exitCode);
}

if (cliArgs[0] === "attempt") {
const exitCode = await runAttempt(cliArgs.slice(1));
await awaitOpportunisticUpdateCheck(updateCheck);
process.exit(exitCode);
}

const exitCode = runCli(cliArgs, { packageName });
await awaitOpportunisticUpdateCheck(updateCheck);
process.exit(exitCode);
33 changes: 33 additions & 0 deletions packages/gittensory-miner/lib/attempt-cli.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type { CodingAgentExecutionMode } from "@jsonbored/gittensory-engine";
import type { AttemptDeps } from "./attempt-runner.js";
import type { ClaimLedger } from "./claim-ledger.js";
import type { EventLedger } from "./event-ledger.js";
import type { AttemptLog } from "./attempt-log.js";
import type { GovernorLedger } from "./governor-ledger.js";
import type { WorktreeAllocator } from "./worktree-allocator.js";

export type ParsedAttemptArgs =
| { error: string }
| { repoFullName: string; issueNumber: number; minerLogin: string; base: string; live: boolean; json: boolean };

export function parseAttemptArgs(args: string[]): ParsedAttemptArgs;

export function buildAttemptDeps(
env: Record<string, string | undefined>,
ledgers: { claimLedger: ClaimLedger; eventLedger: EventLedger; attemptLog: AttemptLog; governorLedger: GovernorLedger; nowMs: number },
): AttemptDeps;

export type RunAttemptOptions = {
env?: Record<string, string | undefined>;
nowMs?: number;
attemptId?: string;
resolveCodingAgentModeFromConfig?: (config: { env?: Record<string, string | undefined> }) => CodingAgentExecutionMode;
openWorktreeAllocator?: () => WorktreeAllocator;
openClaimLedger?: () => ClaimLedger;
initEventLedger?: () => EventLedger;
initAttemptLog?: () => AttemptLog;
initGovernorLedger?: () => GovernorLedger;
buildAttemptDeps?: typeof buildAttemptDeps;
};

export function runAttempt(args: string[], options?: RunAttemptOptions): Promise<number>;
221 changes: 221 additions & 0 deletions packages/gittensory-miner/lib/attempt-cli.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
// CLI dispatch for the real attempt pipeline (#5132, Wave 3.5). Wires bin/gittensory-miner.js's `attempt`
// subcommand to real infrastructure: worktree allocation (worktree-allocator.js's first real, non-test
// caller), the four ledgers (claim/event/attempt-log/governor), the real coding-agent driver (#5131) and
// slop assessor (#5133), the fetchLiveIssueSnapshot/executeLocalWrite built alongside this file, and mode
// resolution.
//
// KNOWN, DELIBERATE GAP: runMinerAttempt requires `loopInput.reviewContext: SelfReviewContext` (issue/PR/
// manifest data at live-gate fidelity, tracked by #5145) AND a full coding-task spec (title/instructions/
// acceptanceCriteriaPath, derived from the target issue -- no builder for that exists anywhere in this
// package either, a second gap discovered while building this file and noted on #5132). Rather than
// fabricate placeholder data for either -- which would let a self-review pass "look real" while checking
// nothing -- this command builds and verifies every OTHER real dependency, then reports the block clearly
// instead of calling runMinerAttempt with an invalid or fabricated input.

import { resolveCodingAgentModeFromConfig } from "@jsonbored/gittensory-engine";
import { constructProductionCodingAgentDriver } from "./coding-agent-construction.js";
import { runSlopAssessment } from "./slop-assessment.js";
import { fetchLiveIssueSnapshot } from "./live-issue-snapshot.js";
import { executeLocalWrite } from "./execute-local-write.js";
import { openClaimLedger } from "./claim-ledger.js";
import { initEventLedger } from "./event-ledger.js";
import { initAttemptLog } from "./attempt-log.js";
import { initGovernorLedger } from "./governor-ledger.js";
import { openWorktreeAllocator } from "./worktree-allocator.js";

const ATTEMPT_USAGE = "Usage: gittensory-miner attempt <owner/repo> <issue#> --miner-login <login> [--base <branch>] [--live] [--json]";

function parseRepoTarget(value) {
const trimmed = typeof value === "string" ? value.trim() : "";
const [owner, repo, extra] = trimmed.split("/");
if (!owner || !repo || extra !== undefined) return null;
return `${owner}/${repo}`;
}

export function parseAttemptArgs(args) {
const options = { json: false, minerLogin: null, base: "main", live: false };
const positional = [];

for (let index = 0; index < args.length; index += 1) {
const token = args[index];
if (token === "--json") {
options.json = true;
continue;
}
// Opt-in only: resolveCodingAgentModeFromConfig's own default (no agentDryRun override) is "live", not
// "dry_run" -- so #5132's "dry-run is default" acceptance criteria (#2342) has to be enforced HERE, by
// requiring an explicit --live flag before this command will ever request live mode.
if (token === "--live") {
options.live = true;
continue;
}
if (token === "--miner-login") {
const value = args[index + 1];
if (!value || value.startsWith("-")) return { error: ATTEMPT_USAGE };
options.minerLogin = value;
index += 1;
continue;
}
if (token === "--base") {
const value = args[index + 1];
if (!value || value.startsWith("-")) return { error: ATTEMPT_USAGE };
options.base = value;
index += 1;
continue;
}
if (token.startsWith("-")) return { error: `Unknown option: ${token}` };
positional.push(token);
}

if (positional.length !== 2) return { error: ATTEMPT_USAGE };
const repoFullName = parseRepoTarget(positional[0]);
if (!repoFullName) return { error: `Repository must be in owner/repo form: ${positional[0]}` };
const issueNumber = Number(positional[1]);
if (!Number.isInteger(issueNumber) || issueNumber < 1) {
return { error: `Issue number must be a positive integer: ${positional[1]}` };
}
if (!options.minerLogin) return { error: `--miner-login is required. ${ATTEMPT_USAGE}` };

return {
repoFullName,
issueNumber,
minerLogin: options.minerLogin,
base: options.base,
live: options.live,
json: options.json,
};
}

/**
* Assemble a real AttemptDeps object: every field wired to a genuine implementation (the #5131 driver, the
* #5133 slop assessor, the four real ledgers passed in, and the fetchLiveIssueSnapshot/executeLocalWrite
* built alongside this file). Throws if the coding-agent driver is unconfigured (fails closed, matching
* constructProductionCodingAgentDriver's own contract) -- callers should report that clearly rather than
* silently falling back to a driver that could never run.
*
* @param {Record<string, string | undefined>} env
* @param {{
* claimLedger: import("./claim-ledger.js").ClaimLedger,
* eventLedger: import("./event-ledger.js").EventLedger,
* attemptLog: import("./attempt-log.js").AttemptLog,
* governorLedger: import("./governor-ledger.js").GovernorLedger,
* nowMs: number,
* }} ledgers
* @returns {import("./attempt-runner.js").AttemptDeps}
*/
export function buildAttemptDeps(env, ledgers) {
return {
driver: constructProductionCodingAgentDriver(env),
runSlopAssessment: (input) => runSlopAssessment(input),
appendAttemptLogEvent: (event) => ledgers.attemptLog.appendAttemptLogEvent(event),
claimLedger: ledgers.claimLedger,
fetchLiveIssueSnapshot: (repoFullName, issueNumber) => fetchLiveIssueSnapshot(repoFullName, issueNumber, { githubToken: env.GITHUB_TOKEN }),
eventLedger: ledgers.eventLedger,
governorLedgerAppend: (event) => ledgers.governorLedger.appendGovernorEvent(event),
nowMs: ledgers.nowMs,
executeLocalWrite: (spec) => executeLocalWrite(spec),
};
}

/**
* Run the `attempt` CLI subcommand. Acquires a real worktree slot (worktree-allocator.js's first
* production caller), assembles real AttemptDeps, then -- since no SelfReviewContext fetcher or
* coding-task-spec builder exists yet -- reports the block instead of calling runMinerAttempt with
* fabricated data. See this file's header for why.
*/
export async function runAttempt(args, options = {}) {
const parsed = parseAttemptArgs(args);
if ("error" in parsed) {
console.error(parsed.error);
return 2;
}

const env = options.env ?? process.env;
const nowMs = options.nowMs ?? Date.now();
const resolveMode = options.resolveCodingAgentModeFromConfig ?? resolveCodingAgentModeFromConfig;
const mode = resolveMode({ env, agentDryRun: !parsed.live });

if (mode === "paused") {
console.error(
`Coding-agent execution is globally paused (MINER_CODING_AGENT_PAUSED). Not running attempt for ${parsed.repoFullName}#${parsed.issueNumber}.`,
);
return 3;
}

const attemptId = options.attemptId ?? `${parsed.repoFullName.replace("/", "_")}-${parsed.issueNumber}-${nowMs}`;

let allocator = null;
let claimLedger = null;
let eventLedger = null;
let attemptLog = null;
let governorLedger = null;
let allocation = null;

try {
allocator = (options.openWorktreeAllocator ?? openWorktreeAllocator)();
claimLedger = (options.openClaimLedger ?? openClaimLedger)();
eventLedger = (options.initEventLedger ?? initEventLedger)();
attemptLog = (options.initAttemptLog ?? initAttemptLog)();
governorLedger = (options.initGovernorLedger ?? initGovernorLedger)();

allocation = allocator.acquire(attemptId, parsed.repoFullName);

try {
const buildDeps = options.buildAttemptDeps ?? buildAttemptDeps;
buildDeps(env, { claimLedger, eventLedger, attemptLog, governorLedger, nowMs });
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
console.error(`Attempt for ${parsed.repoFullName}#${parsed.issueNumber} is blocked: ${reason}`);
return 3;
}

const reason = "missing_self_review_context_and_task_spec";
const blockedResult = {
outcome: "blocked_missing_prerequisite",
reason,
trackingIssue: 5145,
repoFullName: parsed.repoFullName,
issueNumber: parsed.issueNumber,
minerLogin: parsed.minerLogin,
base: parsed.base,
mode,
attemptId,
worktreePath: allocation.worktreePath,
};

// "attempt_aborted" is the closest fit in ATTEMPT_LOG_EVENT_TYPES's fixed vocabulary
// (@jsonbored/gittensory-engine) for "never started because a hard prerequisite is missing".
attemptLog.appendAttemptLogEvent({
eventType: "attempt_aborted",
attemptId,
actionClass: "open_pr",
mode,
reason,
payload: { repoFullName: parsed.repoFullName, issueNumber: parsed.issueNumber, trackingIssue: 5145 },
});
eventLedger.appendEvent({
type: "attempt_blocked",
repoFullName: parsed.repoFullName,
payload: { issueNumber: parsed.issueNumber, reason, trackingIssue: 5145 },
});

if (parsed.json) {
console.log(JSON.stringify(blockedResult, null, 2));
} else {
console.log(
`Attempt for ${parsed.repoFullName}#${parsed.issueNumber} is blocked: no SelfReviewContext fetcher or coding-task-spec builder yet (tracked by #5145). Worktree, ledgers, driver, live-issue fetch, and local-write execution are wired and ready; runMinerAttempt was not invoked.`,
);
}
return 4;
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
return 2;
} finally {
if (allocation && allocator) allocator.release(attemptId);
allocator?.close();
claimLedger?.close();
eventLedger?.close();
attemptLog?.close();
governorLedger?.close();
}
}
1 change: 1 addition & 0 deletions packages/gittensory-miner/lib/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export function printHelp(input) {
" gittensory-miner manage poll <owner/repo> <pr#> [--branch <name>] [--json]",
" gittensory-miner discover <owner/repo> [<owner/repo>...] [--json]",
" gittensory-miner discover --search <query> [--json] Fan out, rank, and enqueue candidates",
" gittensory-miner attempt <owner/repo> <issue#> --miner-login <login> [--base <branch>] [--live] [--json]",
" gittensory-miner queue list [--repo <owner/repo>] [--json] List portfolio backlog rows",
" gittensory-miner queue next [--json] Claim the highest-priority queued item",
" gittensory-miner queue done <owner/repo> <identifier> [--json]",
Expand Down
14 changes: 14 additions & 0 deletions packages/gittensory-miner/lib/execute-local-write.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { LocalWriteActionSpec } from "@jsonbored/gittensory-engine";

export type ExecuteLocalWriteResult = {
action: string;
stdout: string;
stderr: string;
code: number | null;
timedOut: boolean;
};

export function executeLocalWrite(
spec: LocalWriteActionSpec,
options?: { cwd?: string; env?: NodeJS.ProcessEnv; timeoutMs?: number },
): Promise<ExecuteLocalWriteResult>;
50 changes: 50 additions & 0 deletions packages/gittensory-miner/lib/execute-local-write.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// Real executeLocalWrite implementation (#5132, Wave 3.5). Mirrors coding-agent-construction.js's
// createRealCliSubprocessSpawn pattern (real child_process, resolve-not-reject on error/timeout so a
// killed/errored process's partial output -- e.g. an auth failure line on stderr -- is never lost to an
// unhandled rejection) but for LocalWriteActionSpec.command: a single shell-safe string (built with
// packages/gittensory-engine/src/miner/local-write-tools.ts's own single-quote escaping), not the
// cmd/args-array CliSubprocessSpawnFn contract the coding-agent driver itself uses. Runs it via `sh -c` in
// the given working directory. Per local-write-tools.ts's own boundary comment, this always runs with
// whatever `gh`/`git` credentials are already configured in that environment -- gittensory never performs
// the write itself.

import { spawn } from "node:child_process";

const DEFAULT_TIMEOUT_MS = 120_000;

/**
* @param {import("@jsonbored/gittensory-engine").LocalWriteActionSpec} spec
* @param {{ cwd?: string, env?: NodeJS.ProcessEnv, timeoutMs?: number }} [options]
* @returns {Promise<{ action: string, stdout: string, stderr: string, code: number | null, timedOut: boolean }>}
*/
export function executeLocalWrite(spec, options = {}) {
const cwd = options.cwd ?? process.cwd();
const env = options.env ?? process.env;
const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : DEFAULT_TIMEOUT_MS;

return new Promise((resolve) => {
const child = spawn("sh", ["-c", spec.command], { cwd, env, stdio: ["ignore", "pipe", "pipe"] });
let stdout = "";
let stderr = "";
const timer = setTimeout(() => {
child.kill("SIGKILL");
resolve({ action: spec.action, stdout, stderr, code: null, timedOut: true });
}, timeoutMs);
child.stdout?.on("data", (chunk) => {
stdout += chunk.toString("utf8");
});
child.stderr?.on("data", (chunk) => {
stderr += chunk.toString("utf8");
});
child.on("error", (err) => {
// A spawn-level error (e.g. no `sh` on PATH) fires before the child ever produces output -- mirrors
// createRealCliSubprocessSpawn's own identical handling.
clearTimeout(timer);
resolve({ action: spec.action, stdout, stderr: err.message, code: null, timedOut: false });
});
child.on("close", (code) => {
clearTimeout(timer);
resolve({ action: spec.action, stdout, stderr, code, timedOut: false });
});
});
}
16 changes: 16 additions & 0 deletions packages/gittensory-miner/lib/live-issue-snapshot.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { LiveIssueSnapshot } from "./submission-freshness-check.js";

// A narrower shape than `typeof fetch` on purpose: this module only ever calls it with a string URL and a
// plain POST init, and the ambient `fetch` type in this repo's TS program is Cloudflare-Workers-flavored
// (RequestInfo<CfProperties> | URL), which is both irrelevant here (this package runs under plain Node) and
// stricter than any real caller needs.
export type LiveIssueSnapshotFetch = (
url: string,
init: { method: string; headers: Record<string, string>; body: string },
) => Promise<{ ok: boolean; status: number; json: () => Promise<unknown> }>;

export function fetchLiveIssueSnapshot(
repoFullName: string,
issueNumber: number,
options?: { githubToken?: string; graphqlUrl?: string; fetchImpl?: LiveIssueSnapshotFetch },
): Promise<LiveIssueSnapshot | null>;
Loading