ai-foreman 1.7.19 → 1.7.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -5
- package/dist/activity.d.ts +3 -0
- package/dist/activity.js +18 -6
- package/dist/adapters/claude.d.ts +16 -0
- package/dist/adapters/claude.js +28 -3
- package/dist/adapters/codex.d.ts +20 -0
- package/dist/adapters/codex.js +99 -5
- package/dist/adapters/recovering.d.ts +9 -0
- package/dist/adapters/recovering.js +10 -0
- package/dist/adapters/types.d.ts +15 -0
- package/dist/agentRun.d.ts +7 -0
- package/dist/agentRun.js +24 -13
- package/dist/branch/currentGuard.d.ts +1 -0
- package/dist/branch/currentGuard.js +1 -0
- package/dist/branch/finalization.js +6 -1
- package/dist/branch/git.d.ts +1 -0
- package/dist/branch/git.js +15 -1
- package/dist/branch/runner.d.ts +2 -0
- package/dist/branch/runner.js +92 -5
- package/dist/buildAdmission.d.ts +61 -0
- package/dist/buildAdmission.js +286 -0
- package/dist/buildApproval.d.ts +2 -0
- package/dist/buildApproval.js +17 -0
- package/dist/buildAssignment.d.ts +33 -0
- package/dist/buildAssignment.js +215 -0
- package/dist/buildInterventions.d.ts +90 -0
- package/dist/buildInterventions.js +334 -0
- package/dist/buildLaunchGate.d.ts +1 -0
- package/dist/buildLaunchGate.js +36 -0
- package/dist/buildOwnershipReconciliation.d.ts +19 -0
- package/dist/buildOwnershipReconciliation.js +41 -0
- package/dist/buildOwnershipRepair.d.ts +33 -0
- package/dist/buildOwnershipRepair.js +111 -0
- package/dist/buildRuns.d.ts +24 -1
- package/dist/buildRuns.js +216 -70
- package/dist/buildSyntheticWork.d.ts +2 -0
- package/dist/buildSyntheticWork.js +30 -0
- package/dist/buildWorkAdmission.d.ts +17 -0
- package/dist/buildWorkAdmission.js +57 -0
- package/dist/buildWorkAuthorization.d.ts +2 -0
- package/dist/buildWorkAuthorization.js +23 -0
- package/dist/buildWorkContext.d.ts +3 -0
- package/dist/buildWorkContext.js +6 -0
- package/dist/buildWorkMigration.d.ts +9 -0
- package/dist/buildWorkMigration.js +206 -0
- package/dist/buildWorkReconciliation.d.ts +12 -0
- package/dist/buildWorkReconciliation.js +70 -0
- package/dist/builderGuidanceFollowup.d.ts +15 -0
- package/dist/builderGuidanceFollowup.js +34 -0
- package/dist/cli/doctor.js +3 -2
- package/dist/cli/graph.d.ts +2 -0
- package/dist/cli/graph.js +75 -0
- package/dist/cli/manager.d.ts +8 -0
- package/dist/cli/manager.js +234 -12
- package/dist/cli/recovery.js +3 -2
- package/dist/cli/runtimeAuthPrompt.d.ts +5 -0
- package/dist/cli/runtimeAuthPrompt.js +41 -7
- package/dist/cli/start.d.ts +8 -0
- package/dist/cli/start.js +2091 -1849
- package/dist/cli/status.js +20 -5
- package/dist/cli/tickets.js +9 -7
- package/dist/continuity.d.ts +16 -1
- package/dist/continuity.js +68 -12
- package/dist/foreman.d.ts +12 -4
- package/dist/foreman.js +397 -60
- package/dist/graph/boundary.d.ts +15 -0
- package/dist/graph/boundary.js +50 -0
- package/dist/graph/bridge.d.ts +9 -0
- package/dist/graph/bridge.js +146 -0
- package/dist/graph/bridge.py +205 -0
- package/dist/graph/config.d.ts +11 -0
- package/dist/graph/config.js +40 -0
- package/dist/graph/corpus.d.ts +16 -0
- package/dist/graph/corpus.js +188 -0
- package/dist/graph/derived.d.ts +17 -0
- package/dist/graph/derived.js +73 -0
- package/dist/graph/host.d.ts +4 -0
- package/dist/graph/host.js +160 -0
- package/dist/graph/install.d.ts +3 -0
- package/dist/graph/install.js +50 -0
- package/dist/graph/lifecycle.d.ts +11 -0
- package/dist/graph/lifecycle.js +124 -0
- package/dist/graph/maintenance.d.ts +43 -0
- package/dist/graph/maintenance.js +301 -0
- package/dist/graph/manager.d.ts +9 -0
- package/dist/graph/manager.js +92 -0
- package/dist/graph/preparation.d.ts +41 -0
- package/dist/graph/preparation.js +43 -0
- package/dist/graph/read.d.ts +27 -0
- package/dist/graph/read.js +152 -0
- package/dist/graph/semantic.d.ts +17 -0
- package/dist/graph/semantic.js +27 -0
- package/dist/graph/session.d.ts +8 -0
- package/dist/graph/session.js +92 -0
- package/dist/graph/sources.d.ts +4 -0
- package/dist/graph/sources.js +11 -0
- package/dist/graph/storage.d.ts +38 -0
- package/dist/graph/storage.js +159 -0
- package/dist/graph/turn.d.ts +17 -0
- package/dist/graph/turn.js +360 -0
- package/dist/graph/util.d.ts +5 -0
- package/dist/graph/util.js +51 -0
- package/dist/handoffs.d.ts +1 -0
- package/dist/handoffs.js +5 -1
- package/dist/humanDecision.d.ts +21 -2
- package/dist/humanDecision.js +169 -10
- package/dist/index.js +4 -1
- package/dist/installOwnership.d.ts +46 -0
- package/dist/installOwnership.js +440 -0
- package/dist/linuxProbeInventory.d.ts +12 -0
- package/dist/linuxProbeInventory.js +48 -0
- package/dist/log.d.ts +1 -1
- package/dist/log.js +7 -1
- package/dist/managerEvidence.d.ts +25 -0
- package/dist/managerEvidence.js +326 -0
- package/dist/managerEvidenceArtifacts.d.ts +38 -0
- package/dist/managerEvidenceArtifacts.js +118 -0
- package/dist/managerPacket.d.ts +2 -0
- package/dist/managerPacket.js +23 -0
- package/dist/markers.d.ts +1 -1
- package/dist/markers.js +2 -2
- package/dist/observability.js +1 -1
- package/dist/processIdentity.d.ts +21 -1
- package/dist/processIdentity.js +87 -5
- package/dist/providerPhase.d.ts +13 -0
- package/dist/providerPhase.js +26 -0
- package/dist/qaAuthorizedDisposition.d.ts +24 -0
- package/dist/qaAuthorizedDisposition.js +26 -0
- package/dist/qaBuildGate.d.ts +4 -0
- package/dist/qaBuildGate.js +216 -0
- package/dist/qaBuilderCoverage.d.ts +5 -0
- package/dist/qaBuilderCoverage.js +78 -0
- package/dist/qaContractCoverage.d.ts +10 -0
- package/dist/qaContractCoverage.js +55 -0
- package/dist/qaContractDelivery.d.ts +23 -0
- package/dist/qaContractDelivery.js +145 -0
- package/dist/qaContractFreshness.d.ts +15 -0
- package/dist/qaContractFreshness.js +36 -0
- package/dist/qaDeliveryJournal.d.ts +10 -0
- package/dist/qaDeliveryJournal.js +16 -1
- package/dist/qaEffectiveConfig.d.ts +20 -0
- package/dist/qaEffectiveConfig.js +40 -0
- package/dist/qaEvidenceReader.d.ts +14 -0
- package/dist/qaEvidenceReader.js +176 -0
- package/dist/qaFailureDelivery.d.ts +2 -0
- package/dist/qaFailureDelivery.js +107 -10
- package/dist/qaHandbackMigration.d.ts +1 -1
- package/dist/qaHandbackMigration.js +3 -3
- package/dist/qaPreparation.d.ts +26 -0
- package/dist/qaPreparation.js +150 -0
- package/dist/qaPreparationInputs.d.ts +8 -0
- package/dist/qaPreparationInputs.js +47 -0
- package/dist/qaPreparationMetrics.d.ts +145 -0
- package/dist/qaPreparationMetrics.js +120 -0
- package/dist/qaPreparationPolicy.d.ts +5 -0
- package/dist/qaPreparationPolicy.js +51 -0
- package/dist/qaPreparationStore.d.ts +157 -0
- package/dist/qaPreparationStore.js +412 -0
- package/dist/qaProtocolV2.d.ts +28 -0
- package/dist/qaProtocolV2.js +12 -0
- package/dist/qaReview.d.ts +6 -0
- package/dist/qaReview.js +293 -41
- package/dist/qaTimeline.d.ts +18 -0
- package/dist/qaTimeline.js +123 -0
- package/dist/qaVerificationContract.d.ts +17 -0
- package/dist/qaVerificationContract.js +236 -0
- package/dist/readinessCleanup.d.ts +19 -0
- package/dist/readinessCleanup.js +122 -0
- package/dist/readinessGate.d.ts +1 -0
- package/dist/readinessGate.js +82 -0
- package/dist/recoveryGuidance.d.ts +17 -0
- package/dist/recoveryGuidance.js +38 -0
- package/dist/runtimeAuth.d.ts +1 -0
- package/dist/runtimeAuth.js +5 -1
- package/dist/runtimeReadiness.d.ts +13 -0
- package/dist/runtimeReadiness.js +206 -10
- package/dist/sessionIdentity.d.ts +2 -0
- package/dist/sessionIdentity.js +14 -0
- package/dist/sessionLifecycle.js +3 -3
- package/dist/sources/sourceRegistry.js +7 -0
- package/dist/stateTransfer.js +85 -8
- package/dist/statusReporter.d.ts +1 -0
- package/dist/statusReporter.js +1 -0
- package/dist/supervisedStart.d.ts +6 -0
- package/dist/supervisedStart.js +178 -62
- package/dist/supervisor.d.ts +2 -0
- package/dist/supervisor.js +26 -2
- package/dist/terminalStyle.d.ts +21 -0
- package/dist/terminalStyle.js +50 -0
- package/dist/ticketPopulation.d.ts +1 -0
- package/dist/ticketPopulation.js +5 -1
- package/dist/tickets/commands.js +2 -2
- package/dist/tickets/queue.js +1 -1
- package/dist/tickets/ticketSchema.d.ts +198 -0
- package/dist/tickets/ticketSchema.js +2 -0
- package/dist/windowsProbeJob.d.ts +12 -0
- package/dist/windowsProbeJob.js +144 -0
- package/dist/workflowDb.d.ts +121 -6
- package/dist/workflowDb.js +874 -30
- package/dist/workflowReader.d.ts +116 -2
- package/dist/workflowReader.js +131 -4
- package/package.json +89 -8
- package/scripts/copy-graph-assets.mjs +3 -0
- package/scripts/graph-benchmark.mjs +20 -0
- package/scripts/graph-evaluation.mjs +85 -0
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { registerHandbackWriter } from "./qaHandbackMigration.js";
|
|
2
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
3
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
4
|
+
import { realpathSync } from "node:fs";
|
|
5
|
+
import { hostname } from "node:os";
|
|
6
|
+
import { join, resolve } from "node:path";
|
|
7
|
+
import Database from "better-sqlite3";
|
|
8
|
+
import { classifyProcess, processStartIdentity } from "./processIdentity.js";
|
|
9
|
+
export class BuildOwnershipError extends Error {
|
|
10
|
+
code;
|
|
11
|
+
constructor(code, message) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.code = code;
|
|
14
|
+
this.name = "BuildOwnershipError";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export function canonicalProject(project) { return realpathSync(resolve(project)); }
|
|
18
|
+
const localAuthorities = new Map();
|
|
19
|
+
const invocation = new AsyncLocalStorage();
|
|
20
|
+
const localLeases = new Map();
|
|
21
|
+
export function originalBuildLease(project) { return (invocation.getStore()?.leases ?? (invocation.getStore() ? new Map() : localLeases)).get(canonicalProject(project)); }
|
|
22
|
+
export function rememberOriginalLease(project, lease) { const scope = invocation.getStore(); const leases = scope ? (scope.leases ??= new Map()) : localLeases; leases.set(canonicalProject(project), lease); }
|
|
23
|
+
export function forgetOriginalLease(project, lease) { const leases = invocation.getStore()?.leases ?? localLeases; if (leases.get(canonicalProject(project))?.owner === lease.owner)
|
|
24
|
+
leases.delete(canonicalProject(project)); }
|
|
25
|
+
export function withBuildInvocation(work) { return invocation.run({}, work); }
|
|
26
|
+
export function localBuildAuthority(project) { const scope = invocation.getStore(); const authority = scope ? scope.authority : localAuthorities.get(canonicalProject(project)); return authority?.project === canonicalProject(project) ? authority : undefined; }
|
|
27
|
+
export function rememberBuildAuthority(authority) { const scope = invocation.getStore(); if (scope)
|
|
28
|
+
scope.authority = authority;
|
|
29
|
+
else
|
|
30
|
+
localAuthorities.set(authority.project, authority); }
|
|
31
|
+
export function forgetBuildAuthority(authority) { const scope = invocation.getStore(); if (scope?.authority?.token === authority.token)
|
|
32
|
+
delete scope.authority; if (!scope && localAuthorities.get(authority.project)?.token === authority.token)
|
|
33
|
+
localAuthorities.delete(authority.project); }
|
|
34
|
+
export function launchDigest(args) {
|
|
35
|
+
const options = [];
|
|
36
|
+
for (let i = 2; i < args.length; i++) {
|
|
37
|
+
const option = [args[i]];
|
|
38
|
+
if (args[i + 1] !== undefined && !args[i + 1].startsWith("--"))
|
|
39
|
+
option.push(args[++i]);
|
|
40
|
+
options.push(option);
|
|
41
|
+
}
|
|
42
|
+
options.sort((a, b) => a[0].localeCompare(b[0]));
|
|
43
|
+
return createHash("sha256").update(JSON.stringify([args[0], args[1], options])).digest("hex");
|
|
44
|
+
}
|
|
45
|
+
export function checkBuildOwnershipSchema(db) {
|
|
46
|
+
const exists = db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='build_ownership_schema'").get();
|
|
47
|
+
if (!exists)
|
|
48
|
+
return;
|
|
49
|
+
const row = db.prepare("SELECT version FROM build_ownership_schema WHERE singleton=1").get();
|
|
50
|
+
if (!row || ![1, 2, 3].includes(row.version))
|
|
51
|
+
throw new Error(`Unsupported build ownership schema ${row?.version ?? "missing"}; upgrade Rafi before accessing it`);
|
|
52
|
+
}
|
|
53
|
+
const ownershipHeartbeats = new Map();
|
|
54
|
+
export function maintainBuildAdmission(authority) {
|
|
55
|
+
const timer = setInterval(() => {
|
|
56
|
+
let db;
|
|
57
|
+
try {
|
|
58
|
+
db = new Database(join(authority.project, ".rafi/recovery.sqlite3"), { fileMustExist: true });
|
|
59
|
+
db.function("rafi_protocol_v3", () => 1);
|
|
60
|
+
registerHandbackWriter(db);
|
|
61
|
+
const changed = db.prepare("UPDATE build_admission SET record_json=json_set(record_json,'$.heartbeatAt',?) WHERE singleton=1 AND json_extract(record_json,'$.token')=?").run(new Date().toISOString(), authority.token);
|
|
62
|
+
if (changed.changes !== 1)
|
|
63
|
+
stopBuildAdmissionHeartbeat(authority);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
stopBuildAdmissionHeartbeat(authority);
|
|
67
|
+
}
|
|
68
|
+
finally {
|
|
69
|
+
db?.close();
|
|
70
|
+
}
|
|
71
|
+
}, 5000);
|
|
72
|
+
timer.unref();
|
|
73
|
+
ownershipHeartbeats.set(authority.token, timer);
|
|
74
|
+
}
|
|
75
|
+
export function stopBuildAdmissionHeartbeat(authority) {
|
|
76
|
+
const timer = ownershipHeartbeats.get(authority.token);
|
|
77
|
+
if (timer)
|
|
78
|
+
clearInterval(timer);
|
|
79
|
+
ownershipHeartbeats.delete(authority.token);
|
|
80
|
+
}
|
|
81
|
+
/** Additive, transactional storage. Readers never invoke this migration. */
|
|
82
|
+
export function migrateBuildAdmission(db) {
|
|
83
|
+
db.transaction(() => {
|
|
84
|
+
db.exec(`CREATE TABLE IF NOT EXISTS build_ownership_schema(singleton INTEGER PRIMARY KEY CHECK(singleton=1),version INTEGER NOT NULL);
|
|
85
|
+
INSERT OR IGNORE INTO build_ownership_schema VALUES(1,1);`);
|
|
86
|
+
const version = db.prepare("SELECT version FROM build_ownership_schema WHERE singleton=1").get().version;
|
|
87
|
+
if (![1, 2, 3].includes(version))
|
|
88
|
+
throw new Error(`Unsupported build ownership schema ${version}; upgrade Rafi before writing`);
|
|
89
|
+
if (version >= 2) {
|
|
90
|
+
migrateReadinessProtocol(db);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
db.exec(`CREATE TABLE IF NOT EXISTS workflow_lease_sequence(singleton INTEGER PRIMARY KEY CHECK(singleton=1),generation INTEGER NOT NULL);
|
|
94
|
+
INSERT OR IGNORE INTO workflow_lease_sequence VALUES(1,0);
|
|
95
|
+
CREATE TABLE IF NOT EXISTS build_runtime_runs(run_id TEXT PRIMARY KEY);
|
|
96
|
+
CREATE TABLE IF NOT EXISTS build_child_runs(child TEXT PRIMARY KEY REFERENCES workflow_runs(run_id),parent TEXT NOT NULL REFERENCES workflow_runs(run_id),CHECK(child<>parent));
|
|
97
|
+
CREATE TABLE IF NOT EXISTS build_admission(singleton INTEGER PRIMARY KEY CHECK(singleton=1),record_json TEXT NOT NULL);
|
|
98
|
+
CREATE TABLE IF NOT EXISTS build_retry_lineage(predecessor TEXT PRIMARY KEY REFERENCES workflow_runs(run_id),successor TEXT NOT NULL UNIQUE REFERENCES workflow_runs(run_id),project TEXT NOT NULL,created_at TEXT NOT NULL,CHECK(predecessor<>successor));
|
|
99
|
+
CREATE TABLE IF NOT EXISTS build_owned_processes(id TEXT PRIMARY KEY,run_id TEXT NOT NULL,owner TEXT NOT NULL,pid INTEGER,process_start TEXT,host TEXT NOT NULL,state TEXT NOT NULL);
|
|
100
|
+
CREATE TABLE IF NOT EXISTS build_launches(token TEXT PRIMARY KEY,run_id TEXT NOT NULL REFERENCES workflow_runs(run_id),state TEXT NOT NULL,record_json TEXT NOT NULL);
|
|
101
|
+
CREATE UNIQUE INDEX IF NOT EXISTS build_launch_exclusive ON build_launches(run_id) WHERE state IN ('reserved','dispatching');`);
|
|
102
|
+
if (!db.prepare("PRAGMA table_info(build_owned_processes)").all().some(column => column.name === "outcome_json"))
|
|
103
|
+
db.exec("ALTER TABLE build_owned_processes ADD COLUMN outcome_json TEXT");
|
|
104
|
+
db.exec(`CREATE TRIGGER IF NOT EXISTS build_lineage_project BEFORE INSERT ON build_retry_lineage
|
|
105
|
+
WHEN NEW.project<>COALESCE((SELECT json_extract(record_json,'$.project') FROM build_admission WHERE singleton=1),'')
|
|
106
|
+
BEGIN SELECT RAISE(ABORT,'Retry lineage project does not match admission'); END;
|
|
107
|
+
CREATE TRIGGER IF NOT EXISTS build_lineage_cycle BEFORE INSERT ON build_retry_lineage
|
|
108
|
+
WHEN EXISTS(WITH RECURSIVE chain(id) AS (SELECT NEW.successor UNION SELECT successor FROM build_retry_lineage JOIN chain ON predecessor=chain.id) SELECT 1 FROM chain WHERE id=NEW.predecessor)
|
|
109
|
+
BEGIN SELECT RAISE(ABORT,'Retry lineage cycle is invalid'); END;
|
|
110
|
+
CREATE TRIGGER IF NOT EXISTS build_lineage_immutable BEFORE UPDATE ON build_retry_lineage
|
|
111
|
+
BEGIN SELECT RAISE(ABORT,'Retry lineage is immutable'); END;`);
|
|
112
|
+
db.prepare("UPDATE build_ownership_schema SET version=2 WHERE singleton=1").run();
|
|
113
|
+
db.exec("INSERT OR IGNORE INTO build_runtime_runs SELECT run_id FROM workflow_runs WHERE kind='build' AND json_extract(state_json,'$.version') IS NOT NULL");
|
|
114
|
+
const qaTables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all()
|
|
115
|
+
.map(row => row.name).filter(name => /^qa_[a-z_]+$/.test(name) && db.prepare(`PRAGMA table_info(${name})`).all().some(column => column.name === "run_id"));
|
|
116
|
+
for (const table of [...new Set(["operation_journal", "provider_sessions", "role_mutation_leases", "continuity_heads", "continuity_checkpoints", "continuity_events", "handoffs", "compaction_attempts", "recovery_decisions", "recovery_attempts", "role_settings", "publication_transactions", "branch_resume_sessions", ...qaTables])]) {
|
|
117
|
+
for (const action of ["INSERT", "UPDATE", "DELETE"]) {
|
|
118
|
+
const target = action === "DELETE" ? "OLD" : "NEW";
|
|
119
|
+
db.exec(`DROP TRIGGER IF EXISTS build_fence_${table}_${action.toLowerCase()};
|
|
120
|
+
CREATE TRIGGER build_fence_${table}_${action.toLowerCase()} BEFORE ${action} ON ${table}
|
|
121
|
+
WHEN (EXISTS(SELECT 1 FROM build_admission) OR rafi_build_writer_run()<>'' OR EXISTS(SELECT 1 FROM build_runtime_runs WHERE run_id=${target}.run_id))
|
|
122
|
+
AND NOT EXISTS(SELECT 1 FROM build_admission WHERE json_extract(record_json,'$.token')=rafi_build_writer_token() AND (json_extract(record_json,'$.runId')=${target}.run_id OR EXISTS(SELECT 1 FROM build_child_runs c WHERE c.child=${target}.run_id AND c.parent=json_extract(record_json,'$.runId')) OR EXISTS(SELECT 1 FROM workflow_runs r WHERE r.run_id=${target}.run_id AND r.status='superseded' AND json_extract(r.state_json,'$.supersededBy')=json_extract(record_json,'$.runId'))))
|
|
123
|
+
AND NOT (NOT EXISTS(SELECT 1 FROM build_admission) AND EXISTS(SELECT 1 FROM project_lease WHERE owner=rafi_build_lease_owner() AND generation=rafi_build_lease_generation() AND (run_id=${target}.run_id OR EXISTS(SELECT 1 FROM workflow_runs r WHERE r.run_id=project_lease.run_id AND r.kind='recovery' AND json_extract(r.state_json,'$.operation')='build-start-over'))))
|
|
124
|
+
BEGIN SELECT RAISE(ABORT,'Build mutation rejected: original admission authority is stale or missing'); END;`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
migrateReadinessProtocol(db);
|
|
128
|
+
}).immediate();
|
|
129
|
+
}
|
|
130
|
+
export function readAdmission(db) {
|
|
131
|
+
const row = db.prepare("SELECT record_json FROM build_admission WHERE singleton=1").get();
|
|
132
|
+
return row ? JSON.parse(row.record_json) : undefined;
|
|
133
|
+
}
|
|
134
|
+
export function assertAdmission(db, authority) {
|
|
135
|
+
const held = readAdmission(db);
|
|
136
|
+
if (!held || held.token !== authority.token || held.runId !== authority.runId || held.project !== authority.project)
|
|
137
|
+
throw new BuildOwnershipError("stale-owner", "Build admission ownership changed; stale caller cannot mutate or dispatch");
|
|
138
|
+
}
|
|
139
|
+
export function acquireAdmission(db, project, runId, phase) {
|
|
140
|
+
return db.transaction(() => {
|
|
141
|
+
const held = readAdmission(db);
|
|
142
|
+
if (held) {
|
|
143
|
+
const owner = classifyProcess(held.pid, held.processStart, held.host);
|
|
144
|
+
if (owner.state !== "dead")
|
|
145
|
+
throw new BuildOwnershipError(owner.state === "live" ? "active-owner" : "unknown-owner", `Build ${held.runId} admission is ${owner.state}: ${owner.reason}`);
|
|
146
|
+
// A reserved launch cannot spawn until its owner conditionally marks it
|
|
147
|
+
// dispatching. Fence that transition before replacing a proven-dead owner.
|
|
148
|
+
for (const row of db.prepare("SELECT record_json FROM build_launches WHERE run_id=? AND state='reserved'").all(held.runId)) {
|
|
149
|
+
const launch = JSON.parse(row.record_json);
|
|
150
|
+
db.prepare("UPDATE build_launches SET state='failed',record_json=? WHERE token=?").run(JSON.stringify({ ...launch, state: "failed" }), launch.token);
|
|
151
|
+
}
|
|
152
|
+
const pending = db.prepare("SELECT 1 FROM build_launches WHERE run_id=? AND state='dispatching'").get(held.runId);
|
|
153
|
+
if (pending)
|
|
154
|
+
throw new Error(`Build ${held.runId} has an unresolved launch; reconcile its child before retrying`);
|
|
155
|
+
}
|
|
156
|
+
db.prepare("INSERT OR IGNORE INTO build_runtime_runs VALUES(?)").run(runId);
|
|
157
|
+
const authority = { token: randomUUID(), project: canonicalProject(project), runId, pid: process.pid, host: hostname(), processStart: processStartIdentity(), phase, heartbeatAt: new Date().toISOString() };
|
|
158
|
+
db.prepare("INSERT OR REPLACE INTO build_admission VALUES(1,?)").run(JSON.stringify(authority));
|
|
159
|
+
return authority;
|
|
160
|
+
}).immediate();
|
|
161
|
+
}
|
|
162
|
+
export function releaseAdmission(db, authority) {
|
|
163
|
+
db.transaction(() => {
|
|
164
|
+
assertAdmission(db, authority);
|
|
165
|
+
if (db.prepare("SELECT 1 FROM build_launches WHERE run_id=? AND state IN ('reserved','dispatching')").get(authority.runId))
|
|
166
|
+
throw new Error("Cannot release admission with an unresolved launch");
|
|
167
|
+
db.prepare("DELETE FROM build_admission WHERE singleton=1").run();
|
|
168
|
+
}).immediate();
|
|
169
|
+
}
|
|
170
|
+
export function reserveLaunch(db, authority, role, digest, protocol) {
|
|
171
|
+
return db.transaction(() => {
|
|
172
|
+
assertAdmission(db, authority);
|
|
173
|
+
const launch = { token: randomUUID(), runId: authority.runId, project: authority.project, launcher: authority.token, role, digest, protocol, state: "reserved", createdAt: new Date().toISOString() };
|
|
174
|
+
db.prepare("INSERT INTO build_launches VALUES(?,?,?,?)").run(launch.token, launch.runId, launch.state, JSON.stringify(launch));
|
|
175
|
+
return launch;
|
|
176
|
+
}).immediate();
|
|
177
|
+
}
|
|
178
|
+
export function readLaunch(db, token) {
|
|
179
|
+
const row = db.prepare("SELECT record_json FROM build_launches WHERE token=?").get(token);
|
|
180
|
+
return row ? JSON.parse(row.record_json) : undefined;
|
|
181
|
+
}
|
|
182
|
+
export function setLaunchState(db, authority, token, state) {
|
|
183
|
+
db.transaction(() => {
|
|
184
|
+
assertAdmission(db, authority);
|
|
185
|
+
const launch = readLaunch(db, token);
|
|
186
|
+
if (state === "failed" && launch?.state === "failed" && launch.launcher === authority.token)
|
|
187
|
+
return;
|
|
188
|
+
if (!launch || launch.launcher !== authority.token || !(state === "dispatching" ? launch.state === "reserved" : ["reserved", "dispatching"].includes(launch.state)))
|
|
189
|
+
throw new Error("Invalid launch transition");
|
|
190
|
+
db.prepare("UPDATE build_launches SET state=?,record_json=? WHERE token=?").run(state, JSON.stringify({ ...launch, state }), token);
|
|
191
|
+
}).immediate();
|
|
192
|
+
}
|
|
193
|
+
export function claimLaunch(db, project, runId, token, role, digest) {
|
|
194
|
+
return db.transaction(() => {
|
|
195
|
+
const launch = readLaunch(db, token);
|
|
196
|
+
const owner = readAdmission(db);
|
|
197
|
+
if (!launch || launch.state !== "dispatching" || launch.project !== canonicalProject(project) || launch.runId !== runId || launch.role !== role || launch.digest !== digest || owner?.token !== launch.launcher || owner.host !== hostname())
|
|
198
|
+
throw new BuildOwnershipError("invalid-launch", "Invalid, stale, or already claimed build launch authorization");
|
|
199
|
+
if (launch.protocol === "registered-v2" && (!launch.acknowledged || launch.child?.pid !== process.pid || launch.child.processStart !== processStartIdentity() || launch.child.host !== hostname()))
|
|
200
|
+
throw new BuildOwnershipError("invalid-launch", "Build child registration was not acknowledged");
|
|
201
|
+
const authority = { ...owner, token: randomUUID(), phase: role, pid: process.pid, host: hostname(), processStart: processStartIdentity(), heartbeatAt: new Date().toISOString() };
|
|
202
|
+
db.prepare("UPDATE build_admission SET record_json=? WHERE singleton=1").run(JSON.stringify(authority));
|
|
203
|
+
db.prepare("UPDATE build_launches SET state='claimed',record_json=? WHERE token=?").run(JSON.stringify({ ...launch, state: "claimed", claimant: authority }), token);
|
|
204
|
+
return authority;
|
|
205
|
+
}).immediate();
|
|
206
|
+
}
|
|
207
|
+
/** Child registration grants no mutation/provider authority. Competes atomically with retirement. */
|
|
208
|
+
export function registerLaunchChild(db, token, project) {
|
|
209
|
+
db.transaction(() => {
|
|
210
|
+
const launch = readLaunch(db, token);
|
|
211
|
+
if (!launch || launch.state !== "dispatching" || launch.protocol !== "registered-v2" || launch.project !== canonicalProject(project) || launch.child)
|
|
212
|
+
throw new BuildOwnershipError("invalid-launch", "Launch is retired, already registered, or invalid");
|
|
213
|
+
const child = { pid: process.pid, processStart: processStartIdentity(), host: hostname() };
|
|
214
|
+
if (child.processStart === "unavailable")
|
|
215
|
+
throw new BuildOwnershipError("unknown-owner", "Cannot register child process identity");
|
|
216
|
+
db.prepare("UPDATE build_launches SET record_json=? WHERE token=?").run(JSON.stringify({ ...launch, child }), token);
|
|
217
|
+
}).immediate();
|
|
218
|
+
}
|
|
219
|
+
export function acknowledgeLaunchChild(db, authority, token, pid) {
|
|
220
|
+
db.transaction(() => {
|
|
221
|
+
assertAdmission(db, authority);
|
|
222
|
+
const launch = readLaunch(db, token);
|
|
223
|
+
if (!launch || launch.state !== "dispatching" || launch.launcher !== authority.token || launch.child?.pid !== pid || classifyProcess(pid, launch.child.processStart, launch.child.host).state !== "live")
|
|
224
|
+
throw new BuildOwnershipError("invalid-launch", "Registered child cannot be acknowledged");
|
|
225
|
+
db.prepare("UPDATE build_launches SET record_json=? WHERE token=?").run(JSON.stringify({ ...launch, acknowledged: true }), token);
|
|
226
|
+
}).immediate();
|
|
227
|
+
}
|
|
228
|
+
/** Restricted retirement only; does not grant execution authority or replay work. */
|
|
229
|
+
export function reconcileLaunch(db, token, original) {
|
|
230
|
+
return db.transaction(() => {
|
|
231
|
+
const launch = readLaunch(db, token);
|
|
232
|
+
if (!launch || launch.state === "failed")
|
|
233
|
+
return "retired";
|
|
234
|
+
if (launch.state === "claimed")
|
|
235
|
+
return "claimed";
|
|
236
|
+
const owner = readAdmission(db);
|
|
237
|
+
if (!owner || owner.token !== launch.launcher)
|
|
238
|
+
return "unknown";
|
|
239
|
+
const callerOwns = original?.token === owner.token;
|
|
240
|
+
if (!callerOwns && classifyProcess(owner.pid, owner.processStart, owner.host).state !== "dead")
|
|
241
|
+
return "unknown";
|
|
242
|
+
// New gated children cannot enter the CLI until durable acknowledgement.
|
|
243
|
+
// Fence the token even if a delayed bootstrap is still registering.
|
|
244
|
+
const safe = launch.state === "reserved" || (launch.protocol === "registered-v2" && (!launch.acknowledged || (launch.child && classifyProcess(launch.child.pid, launch.child.processStart, launch.child.host).state === "dead")));
|
|
245
|
+
if (!safe)
|
|
246
|
+
return "unknown";
|
|
247
|
+
db.prepare("UPDATE build_launches SET state='failed',record_json=? WHERE token=?").run(JSON.stringify({ ...launch, state: "failed" }), token);
|
|
248
|
+
return "retired";
|
|
249
|
+
}).immediate();
|
|
250
|
+
}
|
|
251
|
+
/** Old open connections lack this function, even if they passed a prior schema check. */
|
|
252
|
+
function migrateReadinessProtocol(db) {
|
|
253
|
+
const version = db.prepare("SELECT version FROM build_ownership_schema").get().version;
|
|
254
|
+
if (version === 3)
|
|
255
|
+
return;
|
|
256
|
+
const owner = readAdmission(db);
|
|
257
|
+
// Leave legacy inspection/restricted cleanup available. New execution requires v3.
|
|
258
|
+
if (owner && classifyProcess(owner.pid, owner.processStart, owner.host).state !== "dead")
|
|
259
|
+
return;
|
|
260
|
+
const lease = db.prepare("SELECT pid,process_start,host FROM project_lease").get();
|
|
261
|
+
if (lease && classifyProcess(lease.pid, lease.process_start, lease.host).state !== "dead")
|
|
262
|
+
return;
|
|
263
|
+
for (const row of db.prepare("SELECT state_json FROM supervisor_leases WHERE status IN ('running','stopping')").all()) {
|
|
264
|
+
const state = JSON.parse(row.state_json);
|
|
265
|
+
if (!state.pid || classifyProcess(state.pid, state.processStart).state !== "dead")
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
for (const row of db.prepare("SELECT record_json FROM build_launches WHERE state IN ('reserved','dispatching')").all()) {
|
|
269
|
+
const launch = JSON.parse(row.record_json);
|
|
270
|
+
if (reconcileLaunch(db, launch.token) === "unknown")
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
// Local executable ownership must be reconciled before semantic migration.
|
|
274
|
+
if (db.prepare("SELECT 1 FROM build_owned_processes WHERE state<>'quiescent' LIMIT 1").get())
|
|
275
|
+
return;
|
|
276
|
+
// Remote uncertainty survives migration unchanged. Recovery needs the new
|
|
277
|
+
// writer fences to reconcile it; ordinary admission still rejects dispatch uncertainty.
|
|
278
|
+
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").all();
|
|
279
|
+
for (const { name } of tables) {
|
|
280
|
+
if (!/^[a-z_]+$/.test(name))
|
|
281
|
+
throw new Error("Unexpected ownership table");
|
|
282
|
+
for (const action of ["INSERT", "UPDATE", "DELETE"])
|
|
283
|
+
db.exec(`CREATE TRIGGER IF NOT EXISTS protocol_v3_${name}_${action} BEFORE ${action} ON ${name} BEGIN SELECT CASE WHEN rafi_protocol_v3()<>1 THEN RAISE(ABORT,'Incompatible writer protocol') END; END`);
|
|
284
|
+
}
|
|
285
|
+
db.prepare("UPDATE build_ownership_schema SET version=3").run();
|
|
286
|
+
}
|
package/dist/buildApproval.d.ts
CHANGED
|
@@ -7,3 +7,5 @@ export declare function approvedBuildScope(projectDir: string, ticketIds?: reado
|
|
|
7
7
|
export declare function requiresBuildApproval(projectDir: string, ticketIds: readonly string[] | undefined, explicitYes: boolean, consequences?: Record<string, unknown>): boolean;
|
|
8
8
|
/** Bind decisions to current files so an old answer cannot approve a changed scope. */
|
|
9
9
|
export declare function buildScopeRevision(projectDir: string, consequences?: Record<string, unknown>): string;
|
|
10
|
+
/** Reuse a decision only while its source and consequences remain unchanged. */
|
|
11
|
+
export declare function createBuildApprovalGate(projectDir: string, ticketIds: readonly string[] | undefined, explicitYes: boolean, consequences: Record<string, unknown> | undefined, requestApproval: () => Promise<void>): () => Promise<void>;
|
package/dist/buildApproval.js
CHANGED
|
@@ -124,3 +124,20 @@ export function buildScopeRevision(projectDir, consequences) {
|
|
|
124
124
|
});
|
|
125
125
|
return createHash("sha256").update(JSON.stringify([contents, consequences])).digest("hex");
|
|
126
126
|
}
|
|
127
|
+
/** Reuse a decision only while its source and consequences remain unchanged. */
|
|
128
|
+
export function createBuildApprovalGate(projectDir, ticketIds, explicitYes, consequences, requestApproval) {
|
|
129
|
+
let approvedRevision;
|
|
130
|
+
return async () => {
|
|
131
|
+
while (true) {
|
|
132
|
+
const revision = buildScopeRevision(projectDir, consequences);
|
|
133
|
+
if (revision === approvedRevision)
|
|
134
|
+
return;
|
|
135
|
+
if (requiresBuildApproval(projectDir, ticketIds, explicitYes, consequences))
|
|
136
|
+
await requestApproval();
|
|
137
|
+
if (revision !== buildScopeRevision(projectDir, consequences))
|
|
138
|
+
continue;
|
|
139
|
+
approvedRevision = revision;
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { BuilderAdapter } from "./adapters/types.js";
|
|
2
|
+
import type { StepStatus } from "./foreman.js";
|
|
3
|
+
import type { TurnResult } from "./adapters/types.js";
|
|
4
|
+
import { type FrozenQaSourceState } from "./qaSnapshot.js";
|
|
5
|
+
/** Work responses confirm an assignment; they never select the next ticket. */
|
|
6
|
+
export declare function validateBuildAssignment(ticketId: string, status: StepStatus, responseText?: string): string | undefined;
|
|
7
|
+
export interface BuildAssignmentCapture {
|
|
8
|
+
operationId: string;
|
|
9
|
+
runId: string;
|
|
10
|
+
ticketId: string;
|
|
11
|
+
worktree: string;
|
|
12
|
+
before: BuildSourceState;
|
|
13
|
+
instruction: string;
|
|
14
|
+
guidanceIds: string[];
|
|
15
|
+
}
|
|
16
|
+
type BuildSourceState = Pick<FrozenQaSourceState, "digest" | "head" | "contentDigest" | "capturedAt" | "pathInventory" | "stagedDiff" | "unstagedDiff" | "untracked"> & {
|
|
17
|
+
captureMode: "git" | "filesystem";
|
|
18
|
+
omissions?: string[];
|
|
19
|
+
};
|
|
20
|
+
export declare class BuildAssignmentRejected extends Error {
|
|
21
|
+
}
|
|
22
|
+
export interface BuilderGuidanceFollowup {
|
|
23
|
+
reviewAttemptId: string;
|
|
24
|
+
requirementsDigest: string;
|
|
25
|
+
maximum: number;
|
|
26
|
+
authorizationId?: string;
|
|
27
|
+
}
|
|
28
|
+
export declare function assertBuildAssignmentReconciled(projectDir: string, runId: string, worktree?: string): void;
|
|
29
|
+
/** Commit response/source bytes before interpreting any scoped outcome. */
|
|
30
|
+
export declare function beginBuildAssignment(projectDir: string, runId: string, ticketId: string, worktree: string, instruction: string, followup?: BuilderGuidanceFollowup, adapter?: BuilderAdapter): BuildAssignmentCapture;
|
|
31
|
+
export declare function finishBuildAssignment(projectDir: string, assignment: BuildAssignmentCapture, result: TurnResult, status: StepStatus): string | undefined;
|
|
32
|
+
export declare function captureBuildSource(worktree: string): BuildSourceState;
|
|
33
|
+
export {};
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { assertBuilderContractCoverage } from "./qaBuilderCoverage.js";
|
|
2
|
+
import { resolveEffectiveQaConfiguration } from "./qaEffectiveConfig.js";
|
|
3
|
+
import { contractDigest } from "./qaVerificationContract.js";
|
|
4
|
+
import { queueGraphMaintenance } from "./graph/boundary.js";
|
|
5
|
+
import { actualContractSession, assertContractReceipt } from "./qaContractDelivery.js";
|
|
6
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
7
|
+
import { lstatSync, readFileSync, readdirSync, readlinkSync, realpathSync } from "node:fs";
|
|
8
|
+
import { join, relative, resolve } from "node:path";
|
|
9
|
+
import { spawnSync } from "node:child_process";
|
|
10
|
+
import { captureFrozenQaSource } from "./qaSnapshot.js";
|
|
11
|
+
import { WorkflowDb } from "./workflowDb.js";
|
|
12
|
+
import { WorkflowReader } from "./workflowReader.js";
|
|
13
|
+
/** Work responses confirm an assignment; they never select the next ticket. */
|
|
14
|
+
export function validateBuildAssignment(ticketId, status, responseText = "") {
|
|
15
|
+
// An unmarked response may receive the existing bounded response-only repair.
|
|
16
|
+
// Explicit foreign markers must not be erased by a parser error or correction.
|
|
17
|
+
const named = [...responseText.matchAll(/\bticket="([^"]*)"/g)].map(match => match[1]);
|
|
18
|
+
if (status.kind === "unknown" && !status.ticket && named.every(id => id === ticketId))
|
|
19
|
+
return undefined;
|
|
20
|
+
if (status.ticket !== ticketId) {
|
|
21
|
+
return `Builder response named ${status.ticket ?? "no ticket"}, but this turn was assigned to ${ticketId}. Preserved work requires ownership reconciliation before further execution.`;
|
|
22
|
+
}
|
|
23
|
+
if (status.kind === "qa_pass" || status.kind === "qa_fail")
|
|
24
|
+
return `Builder emitted QA status ${status.kind} for assigned ticket ${ticketId}; independent QA is required.`;
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
export class BuildAssignmentRejected extends Error {
|
|
28
|
+
}
|
|
29
|
+
export function assertBuildAssignmentReconciled(projectDir, runId, worktree = projectDir) {
|
|
30
|
+
const reader = new WorkflowReader(projectDir);
|
|
31
|
+
try {
|
|
32
|
+
const runs = [...new Set([runId, ...reader.buildRuns(true).map(run => run.runId)])];
|
|
33
|
+
for (const operation of runs.flatMap(id => reader.operations(id, true))) {
|
|
34
|
+
if (operation.kind !== "build-assignment")
|
|
35
|
+
continue;
|
|
36
|
+
const result = operation.result;
|
|
37
|
+
if (result?.reconciliationId)
|
|
38
|
+
continue;
|
|
39
|
+
if (!result?.rejection && !result?.sourceError && operation.status !== "in_progress" && operation.status !== "uncertain")
|
|
40
|
+
continue;
|
|
41
|
+
const intent = operation.intent;
|
|
42
|
+
if (operation.runId !== runId) {
|
|
43
|
+
if (!intent.worktree)
|
|
44
|
+
continue;
|
|
45
|
+
try {
|
|
46
|
+
if (realpathSync(intent.worktree) !== realpathSync(worktree))
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
if (resolve(intent.worktree) !== resolve(worktree))
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (result?.rejection || result?.sourceError || operation.status === "in_progress" || operation.status === "uncertain") {
|
|
55
|
+
throw new BuildAssignmentRejected(`Build ${runId} requires assignment reconciliation for ${operation.idempotencyKey}: ${result?.rejection ?? result?.sourceError ?? "provider dispatch outcome is unconfirmed"}. Inspect with rafi manager --ask '/qa-conflicts ${runId}'.`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
finally {
|
|
60
|
+
reader.close();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** Commit response/source bytes before interpreting any scoped outcome. */
|
|
64
|
+
export function beginBuildAssignment(projectDir, runId, ticketId, worktree, instruction, followup, adapter) {
|
|
65
|
+
assertBuildAssignmentReconciled(projectDir, runId, worktree);
|
|
66
|
+
const operationId = `build-assignment:${runId}:${randomUUID()}`;
|
|
67
|
+
const db = new WorkflowDb(projectDir);
|
|
68
|
+
try {
|
|
69
|
+
const before = captureBuildSource(worktree);
|
|
70
|
+
let guidance = { text: instruction, ids: [] };
|
|
71
|
+
db.atomic(() => {
|
|
72
|
+
db.ensureRun(runId);
|
|
73
|
+
const admission = db.assertAdmittedWork(runId, ticketId);
|
|
74
|
+
let contractBinding;
|
|
75
|
+
if (db.qaPreparationStore().policy(runId)?.mode === "enforce") {
|
|
76
|
+
const contract = assertContractReceipt(db.qaPreparationStore(), runId, ticketId, admission.requirementsDigest, adapter ? actualContractSession(adapter, worktree, projectDir) : undefined, adapter);
|
|
77
|
+
const session = actualContractSession(adapter, worktree, projectDir);
|
|
78
|
+
const receipt = db.qaPreparationStore().receipts(contract.contentDigest).find(row => row.sessionId === session.sessionId && row.generation === session.generation && row.workspace === session.workspace && row.compactionSequence === session.compactionSequence);
|
|
79
|
+
contractBinding = { digest: contract.contentDigest, revision: contract.revision, receiptOperationId: receipt.operationId, session };
|
|
80
|
+
const qaMake = db.getRun(runId)?.state?.qa?.settings?.make ?? adapter.agent;
|
|
81
|
+
const current = resolveEffectiveQaConfiguration(projectDir, { make: qaMake });
|
|
82
|
+
if (contract.inputs.find(input => input.kind === "rules")?.digest !== current.digest || contract.inputs.find(input => input.kind === "checklist")?.digest !== contractDigest("input-checklist", current.checklist))
|
|
83
|
+
throw new BuildAssignmentRejected("Canonical QA rules, skills or checklist changed after delivery; reconcile the contract before implementation");
|
|
84
|
+
}
|
|
85
|
+
guidance = db.reserveGuidance(runId, ticketId, "builder", operationId, before.digest, instruction);
|
|
86
|
+
if (followup) {
|
|
87
|
+
if (admission.requirementsDigest !== followup.requirementsDigest)
|
|
88
|
+
throw new BuildAssignmentRejected("Ticket requirements changed; renewed scope approval is required before the Builder follow-up");
|
|
89
|
+
if (!guidance.ids.length)
|
|
90
|
+
throw new BuildAssignmentRejected("No eligible Builder guidance remains; no follow-up was dispatched");
|
|
91
|
+
const latest = db.qaReviewAttempts(runId, ticketId).at(-1);
|
|
92
|
+
const head = db.qaTicketHead(runId, ticketId);
|
|
93
|
+
if (latest?.attemptId !== followup.reviewAttemptId || latest.status !== "passed" || head.state !== "passed")
|
|
94
|
+
throw new BuildAssignmentRejected("Builder follow-up requires the current unfinalized QA pass");
|
|
95
|
+
db.reserveQaRemediation(runId, ticketId, latest.attemptId, operationId, followup.maximum, followup.authorizationId);
|
|
96
|
+
db.invalidateUnconsumedQaPassCertificates(runId, ticketId, "builder-guidance-followup");
|
|
97
|
+
db.transitionQa(runId, ticketId, head.revision, { type: "builder-guidance-followup-intended" });
|
|
98
|
+
}
|
|
99
|
+
const instructionDigest = db.putEvidence("qa", Buffer.from(guidance.text));
|
|
100
|
+
db.planOperation({ runId, idempotencyKey: operationId, kind: "build-assignment", intent: { ticketId, worktree, admissionId: admission.assignmentId, requirementsDigest: admission.requirementsDigest, instructionDigest, ...(contractBinding ? { contractBinding } : {}), before: retainSource(db, before), ...(followup ? { managerFollowup: followup } : {}) } });
|
|
101
|
+
db.recordWorkAssignment(runId, ticketId, operationId, { requirementsDigest: admission.requirementsDigest, worktree, sourceDigest: before.digest, ownerGeneration: db.currentLease()?.generation, instructionDigest });
|
|
102
|
+
db.updateOperation(operationId, "in_progress");
|
|
103
|
+
});
|
|
104
|
+
return { operationId, runId, ticketId, worktree, before, instruction: guidance.text, guidanceIds: guidance.ids };
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
db.close();
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
export function finishBuildAssignment(projectDir, assignment, result, status) {
|
|
111
|
+
let rejection = result.failure?.dispatchState === "not-sent" ? undefined : validateBuildAssignment(assignment.ticketId, status, result.text);
|
|
112
|
+
// Source capture can fail after an external turn. Keep the response even then.
|
|
113
|
+
let after;
|
|
114
|
+
let sourceError;
|
|
115
|
+
try {
|
|
116
|
+
after = captureBuildSource(assignment.worktree);
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
sourceError = error instanceof Error ? error.message : String(error);
|
|
120
|
+
}
|
|
121
|
+
if (result.failure?.dispatchState === "not-sent" && after && after.contentDigest !== assignment.before.contentDigest)
|
|
122
|
+
rejection = "Provider claimed an unsubmitted turn but product source changed; reconcile the captured source before continuing";
|
|
123
|
+
const db = new WorkflowDb(projectDir);
|
|
124
|
+
try {
|
|
125
|
+
db.atomic(() => {
|
|
126
|
+
if (!rejection && !result.isError && !result.failure && ["done", "plan_complete"].includes(status.kind) && after) {
|
|
127
|
+
try {
|
|
128
|
+
assertBuilderContractCoverage(db, assignment.runId, assignment.ticketId, assignment.operationId, after.digest);
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
rejection = String(error);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const responseDigest = db.putEvidence("qa", Buffer.from(result.rawResponse ?? result.text));
|
|
135
|
+
const cleanedResponseDigest = db.putEvidence("qa", Buffer.from(result.cleanedResponse ?? result.text));
|
|
136
|
+
const providerInstructionDigest = result.providerInstruction ? db.putEvidence("qa", Buffer.from(result.providerInstruction)) : undefined;
|
|
137
|
+
db.updateOperation(assignment.operationId, result.failure?.dispatchState === "unknown" || result.isError && !result.failure?.dispatchState && status.kind === "unknown" ? "uncertain" : "confirmed", { result: { turnId: result.turnId, responseDigest, cleanedResponseDigest, providerInstructionDigest, providerMetadata: result.providerMetadata, dispatchState: result.failure?.dispatchState, status, rejection, sourceError, after: after ? retainSource(db, after) : undefined } });
|
|
138
|
+
const admission = db.assertAdmittedWork(assignment.runId, assignment.ticketId);
|
|
139
|
+
db.qaPreparationStore().metric(assignment.runId, assignment.ticketId, admission.requirementsDigest, `metric:builder-result:${assignment.operationId}`, "phase-observation", { phase: "builder", durationMs: Math.max(0, Date.now() - Date.parse(assignment.before.capturedAt)), costUsd: result.costAuthoritative ? result.costUsd : null, inputTokens: result.inputTokens ?? null, outputTokens: result.outputTokens ?? null });
|
|
140
|
+
db.finishGuidance(assignment.guidanceIds, "builder", { submitted: result.failure?.dispatchState === "not-sent" ? false : result.turnId ? true : undefined, receipt: result.turnId ? { turnId: result.turnId, responseDigest, providerInstructionDigest, postSourceDigest: after?.digest } : undefined, applied: !rejection && status.kind === "done" && assignment.guidanceIds.every(id => result.text.includes(id)) });
|
|
141
|
+
if (rejection || sourceError) {
|
|
142
|
+
const run = db.getRun(assignment.runId);
|
|
143
|
+
db.transition(assignment.runId, { checkpoint: "build-assignment-rejected", state: { ...run.state, assignmentConflict: { operationId: assignment.operationId, ticketId: assignment.ticketId, worktree: assignment.worktree, responseDigest, rejection, sourceError } }, event: "build_assignment_rejected", payload: { operationId: assignment.operationId, ticketId: assignment.ticketId, returnedTicketId: status.ticket, responseDigest, rejection, sourceError } });
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
finally {
|
|
148
|
+
db.close();
|
|
149
|
+
}
|
|
150
|
+
if (!rejection && !sourceError && !result.isError && status.kind === "done" && after) {
|
|
151
|
+
try {
|
|
152
|
+
queueGraphMaintenance(projectDir, assignment.worktree, assignment.operationId, result.text, after.contentDigest !== assignment.before.contentDigest);
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
console.error(`Graph maintenance deferred: ${String(error)}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return rejection ?? (sourceError ? `Builder source evidence is unavailable: ${sourceError}. Reconcile before continuing.` : undefined);
|
|
159
|
+
}
|
|
160
|
+
function retainSource(db, source) {
|
|
161
|
+
const staged = retainDiff(db, source.stagedDiff);
|
|
162
|
+
const unstaged = retainDiff(db, source.unstagedDiff);
|
|
163
|
+
return { digest: source.digest, head: source.head, captureMode: source.captureMode, omissions: source.omissions, contentDigest: source.contentDigest, capturedAt: source.capturedAt, pathInventory: source.pathInventory,
|
|
164
|
+
stagedDiffDigest: staged.digest, stagedDiffChunks: staged.chunks, stagedDiffBytes: source.stagedDiff.length,
|
|
165
|
+
unstagedDiffDigest: unstaged.digest, unstagedDiffChunks: unstaged.chunks, unstagedDiffBytes: source.unstagedDiff.length,
|
|
166
|
+
untracked: source.untracked.map(file => { const evidence = retainDiff(db, file.bytes); return { path: file.path, mode: file.mode, kind: file.kind, digest: file.digest, evidenceDigest: evidence.digest, evidenceChunks: evidence.chunks, byteLength: file.bytes.length }; }) };
|
|
167
|
+
}
|
|
168
|
+
/** Preserve large product files without changing the shared per-item evidence limits. */
|
|
169
|
+
function retainDiff(db, bytes) {
|
|
170
|
+
if (bytes.length <= 16 * 1024 * 1024)
|
|
171
|
+
return { digest: db.putEvidence("diff", bytes) };
|
|
172
|
+
const chunks = [];
|
|
173
|
+
for (let offset = 0; offset < bytes.length; offset += 8 * 1024 * 1024)
|
|
174
|
+
chunks.push(db.putEvidence("diff", bytes.subarray(offset, offset + 8 * 1024 * 1024)));
|
|
175
|
+
return { chunks };
|
|
176
|
+
}
|
|
177
|
+
export function captureBuildSource(worktree) {
|
|
178
|
+
const head = spawnSync("git", ["-C", worktree, "rev-parse", "--verify", "HEAD"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
|
179
|
+
if (head.status === 0)
|
|
180
|
+
return { ...captureFrozenQaSource(worktree), captureMode: "git" };
|
|
181
|
+
if (head.error?.code !== "ENOENT" && !/not a git repository|Needed a single revision|unknown revision/.test(head.stderr ?? ""))
|
|
182
|
+
throw head.error ?? new Error(`Git source identity is unavailable: ${head.stderr}`);
|
|
183
|
+
const capture = () => {
|
|
184
|
+
const root = resolve(worktree);
|
|
185
|
+
const untracked = [];
|
|
186
|
+
const ignored = new Set([".git", ".rafi", ".foreman", "node_modules"]);
|
|
187
|
+
const visit = (directory) => {
|
|
188
|
+
for (const entry of readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
189
|
+
if (ignored.has(entry.name))
|
|
190
|
+
continue;
|
|
191
|
+
const path = join(directory, entry.name);
|
|
192
|
+
if (entry.isDirectory())
|
|
193
|
+
visit(path);
|
|
194
|
+
else if (entry.isFile() || entry.isSymbolicLink()) {
|
|
195
|
+
const kind = entry.isSymbolicLink() ? "symlink" : "file";
|
|
196
|
+
const bytes = kind === "symlink" ? Buffer.from(readlinkSync(path)) : readFileSync(path);
|
|
197
|
+
untracked.push({ path: relative(root, path).replace(/\\/g, "/"), kind, bytes, mode: lstatSync(path).mode, digest: createHash("sha256").update(bytes).digest("hex") });
|
|
198
|
+
}
|
|
199
|
+
else
|
|
200
|
+
throw new Error(`Unsupported source path type: ${path}`);
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
visit(root);
|
|
204
|
+
const contentDigest = createHash("sha256").update(JSON.stringify(untracked.map(({ bytes: _, ...identity }) => identity))).digest("hex");
|
|
205
|
+
return { digest: contentDigest, head: "unavailable", captureMode: "filesystem", omissions: ["Git baseline/diff unavailable: project is not a repository or has no first commit"], contentDigest, capturedAt: new Date().toISOString(),
|
|
206
|
+
pathInventory: untracked.map(file => ({ path: file.path, staged: [], unstaged: [], untracked: { kind: file.kind, mode: file.mode, digest: file.digest } })), untracked, stagedDiff: Buffer.alloc(0), unstagedDiff: Buffer.alloc(0) };
|
|
207
|
+
};
|
|
208
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
209
|
+
const before = capture();
|
|
210
|
+
const after = capture();
|
|
211
|
+
if (before.digest === after.digest)
|
|
212
|
+
return after;
|
|
213
|
+
}
|
|
214
|
+
throw new Error("Product source changed during assignment evidence capture; retry at a stable boundary");
|
|
215
|
+
}
|