ai-foreman 1.7.19 → 1.7.20
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 +17 -3
- package/dist/branch/runner.js +2 -1
- package/dist/buildAdmission.d.ts +61 -0
- package/dist/buildAdmission.js +284 -0
- package/dist/buildApproval.d.ts +2 -0
- package/dist/buildApproval.js +17 -0
- package/dist/buildLaunchGate.d.ts +1 -0
- package/dist/buildLaunchGate.js +36 -0
- package/dist/buildRuns.d.ts +18 -1
- package/dist/buildRuns.js +198 -58
- package/dist/cli/runtimeAuthPrompt.d.ts +5 -0
- package/dist/cli/runtimeAuthPrompt.js +16 -2
- package/dist/cli/start.d.ts +8 -0
- package/dist/cli/start.js +1980 -1842
- package/dist/continuity.d.ts +5 -1
- package/dist/continuity.js +46 -6
- package/dist/foreman.d.ts +2 -2
- package/dist/foreman.js +79 -33
- package/dist/humanDecision.d.ts +1 -1
- package/dist/humanDecision.js +4 -3
- package/dist/index.js +2 -1
- package/dist/processIdentity.d.ts +21 -1
- package/dist/processIdentity.js +80 -5
- package/dist/qaReview.js +59 -17
- package/dist/readinessCleanup.d.ts +19 -0
- package/dist/readinessCleanup.js +115 -0
- package/dist/readinessGate.d.ts +1 -0
- package/dist/readinessGate.js +82 -0
- package/dist/recoveryGuidance.d.ts +8 -0
- package/dist/recoveryGuidance.js +25 -0
- package/dist/runtimeReadiness.d.ts +12 -0
- package/dist/runtimeReadiness.js +194 -10
- package/dist/stateTransfer.js +35 -7
- package/dist/supervisedStart.d.ts +6 -0
- package/dist/supervisedStart.js +160 -54
- package/dist/supervisor.d.ts +2 -0
- package/dist/supervisor.js +26 -2
- package/dist/windowsProbeJob.d.ts +12 -0
- package/dist/windowsProbeJob.js +144 -0
- package/dist/workflowDb.d.ts +70 -1
- package/dist/workflowDb.js +538 -25
- package/dist/workflowReader.d.ts +7 -0
- package/dist/workflowReader.js +83 -0
- package/package.json +8 -4
package/README.md
CHANGED
|
@@ -166,17 +166,19 @@ Build stall recovery uses current context occupancy, not lifetime token usage. T
|
|
|
166
166
|
|
|
167
167
|
Unchanged, source-bound plan and ticket approvals are reused. Material ticket, plan, delivery, or workflow changes require a new decision, including with `--yes`. Legacy approval evidence that cannot prove unchanged scope requires review. `--steps` counts completed tickets or steps; it is neither a wall-clock limit nor a model-turn budget.
|
|
168
168
|
|
|
169
|
-
Questions are persisted before prompting. Redirected or detached runs return a recoverable needs-input outcome (exit 2). Use `rafi build:attach . --run <id>` to see decisions, `rafi build:decide . --run <id> --decision <id> --choice <id>` to answer, and `rafi
|
|
169
|
+
Questions are persisted before prompting. Redirected or detached runs return a recoverable needs-input outcome (exit 2). Use `rafi build:attach . --run <id>` to see decisions, `rafi build:decide . --run <id> --decision <id> --choice <id>` to answer, and `rafi resume` to select a build and continue. Custom answers additionally use `--choice custom --answer "text"`. Independent eligible tickets continue around a ticket blocker; dependent and unsafe shared-delivery work stays deferred. Answered ticket questions are reconsidered at safe boundaries.
|
|
170
170
|
|
|
171
171
|
CLI starts run under a durable parent supervisor by default. `--detach` writes output under `.rafi/logs/supervisor-<run>.log`; `--no-supervisor` retains direct execution. A preparation-only worker crash may restart within frozen budgets after proving the worker and registered readiness children are stopped. A worker crash with unresolved provider work pauses for reconciliation: it does not silently repeat a possibly completed action. `build:stop` signals only a verified supervisor process incarnation.
|
|
172
172
|
|
|
173
|
-
Runtime deadlines can be overridden in `autonomy.runtime_deadlines` using positive integer milliseconds: `preparation_ms` (120000), `rpc_ms` (60000), `compaction_ms` (120000), `shutdown_ms` (10000), and `turn_ms` (3600000). They are frozen with the run policy. Transport-idle and hard active-turn deadlines have distinct meanings; neither grants permission to repeat uncertain side effects. Attached-process elapsed time includes waiting and is not provider compute time. Activity warns after five minutes without a completed tool or agent turn; intentional human waits are excluded, and the warning alone does not cancel work. Final paused output lists the durable question and
|
|
173
|
+
Runtime deadlines can be overridden in `autonomy.runtime_deadlines` using positive integer milliseconds: `preparation_ms` (120000), `rpc_ms` (60000), `compaction_ms` (120000), `shutdown_ms` (10000), and `turn_ms` (3600000). They are frozen with the run policy. Transport-idle and hard active-turn deadlines have distinct meanings; neither grants permission to repeat uncertain side effects. Attached-process elapsed time includes waiting and is not provider compute time. Activity warns after five minutes without a completed tool or agent turn; intentional human waits are excluded, and the warning alone does not cancel work. Final paused output lists the durable question, its answer command, and the short `rafi resume` recovery command.
|
|
174
174
|
|
|
175
175
|
The current terminal status includes the role/provider, activity, truthful context state, successful compaction count, and handoff generation. At the Builder threshold, Foreman settles the in-flight action, verifies native compaction plus a fresh provider usage sample, and resumes the frozen action. The configured maximum defaults to ten successful compactions per location-scoped provider session; the next crossing uses a validated fresh handoff instead of exceeding it. Disposable QA snapshots never reuse a prior QA conversation in a new `/tmp/rafi-qa-*` worktree; they transfer cumulative state through an accepted handoff.
|
|
176
176
|
|
|
177
177
|
Failed QA reviews use the versioned `RAFI_QA_FAILURE_REPORT_START` / `RAFI_QA_FAILURE_REPORT_END` JSON contract. Foreman validates the report before Builder remediation, namespaces finding IDs by run, ticket, and review, and durably indexes the exact report, remediation response, and bounded fix summary by digest. A malformed failing report receives two correction-only turns in its original session, then a validated fresh successor performs one complete source-bound review followed by up to two correction-only turns. Any later operator-requested fresh successor performs one complete source-bound review followed by five usable correction-only turns.
|
|
178
178
|
|
|
179
|
-
|
|
179
|
+
A completed QA turn with missing/invalid continuity metadata or a malformed response can trigger one automatic complete fresh review per ticket. That allowance is durable across restarts and shared across protocol failures. The old session and snapshot close before the new review starts. Exhausted same-session report correction and malformed resumed report/acknowledgement responses use this route, rather than requiring an immediate manual resume. Earlier invalid responses cannot authorize Builder work or a QA pass. Provider execution failures, uncertain dispatch, invalid acceptance receipts, and modified recovery evidence still pause. Repeated failures exhaust the allowance and preserve recoverable state; independent eligible tickets can continue.
|
|
180
|
+
|
|
181
|
+
Owner-only recovery packets under `.foreman/qa-report-recovery/` bind the review to a full frozen source digest: commit, porcelain status, staged and unstaged bytes, and untracked content. Every fresh attempt captures and compares that complete state. Drift appends the new state and forces a complete review; correction-only reconstruction is never applied to changed source. Packet revisions and the authoritative WorkflowDb recovery head advance together. A successor must have a location-scoped identity, accept a handoff containing the complete resource inventory (purpose, byte count, digest, and recovery requirement), and acknowledge the copied packet before reporting. Paused recovery is available through `rafi resume` or `rafi build:resume` in the project directory. The picker resolves the ticket, current QA revision, and validated QA handoff internally; explicit recovery flags still reject stale revisions.
|
|
180
182
|
|
|
181
183
|
### `ai-foreman manager`
|
|
182
184
|
|
|
@@ -681,3 +683,15 @@ Package output:
|
|
|
681
683
|
- **`special-agents`** — library (rules + skills + agents + composition)
|
|
682
684
|
- **`ai-foreman`** — this runtime
|
|
683
685
|
- **`@rafi-ai/cli`** — CLI for `rafi create` and `rafi compile`
|
|
686
|
+
|
|
687
|
+
### Recovery ownership upgrades
|
|
688
|
+
|
|
689
|
+
Stop existing workers before upgrading. The recovery database now records preparation admission, single-use launches, retry lineage, owned readiness processes and monotonic lease generations. Older binaries must not write an upgraded database; preserve these records when troubleshooting rather than deleting locks. Unknown process identity, incomplete child-launch evidence or an unresolved provider dispatch requires reconciliation. A stale heartbeat alone does not authorize another worker. Read-only resume inspection supports older storage without migrating it.
|
|
690
|
+
|
|
691
|
+
Ownership schema version 2 adds bounded child registration/acknowledgement, explicit child-workflow ownership, and separate readiness/cleanup outcomes. Build readiness requires the original admission and owning project even when the command executes in an external worktree. Both the current build and competing starts wait for verified probe cleanup. Runtime snapshot, dispatch, continuity and session writes retain original authority; releasing a lease does not make those records writable by an arbitrary caller. Completed, cancelled and superseded builds cannot be reactivated by saving a snapshot.
|
|
692
|
+
|
|
693
|
+
Ownership schema version 3 gates provider readiness behind registered helper authorization and records cleanup separately. Cleanup is reachable through both short resume commands even when its owning build is terminal or has no valid build snapshot. Restricted cleanup connections can update only their selected readiness records. Migration waits for local legacy execution to become safely quiescent; persistent compatibility guards then reject already-open older writers as well as new older opens. Uncertain remote provider records survive the upgrade unchanged so their owning recovery flow remains reachable. Ordinary starts remain blocked until those operations are reconciled. A lost acknowledgement cannot revoke an already-authorized helper.
|
|
694
|
+
|
|
695
|
+
Standalone readiness also rejects unverified cleanup. The current process retains its cleanup evidence and checks it before another probe starts; restoring process visibility permits a retry. Standalone commands do not create a build recovery database, so this evidence does not survive process exit.
|
|
696
|
+
|
|
697
|
+
On macOS/Linux, readiness cleanup checks the dedicated process group and inherited probe tags, including tagged descendants that escape the group. This is not a sandbox for descendants that deliberately remove their tag and detach. Unavailable inventory and ambiguous legacy evidence remain unresolved. The Windows readiness implementation targets Windows 10 / Server 2016 APIs, but those minimum versions have not been verified. Native Windows verification remains a release gate. Readiness providers start inside a non-breakaway Job Object with kill-on-close behavior, using built-in Windows PowerShell and `Add-Type`. Standard npm `.cmd`/`.bat` provider shims resolve to direct Node arguments; arbitrary shell shims are rejected. The Windows Job Object applies to readiness probes; it does not add containment for arbitrary Builder commands. Native regression tests are in `.github/workflows/build-recovery.yml`; the implementation plan records actual platform verification.
|
package/dist/branch/runner.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { formatRecoveryCommand } from "../recoveryGuidance.js";
|
|
1
2
|
import { WorkflowReader } from "../workflowReader.js";
|
|
2
3
|
import { buildScopeRevision } from "../buildApproval.js";
|
|
3
4
|
import { beginQaFinalization, completeQaFinalization, verifyPendingQaFinalizationSource, compactWithRetry, runIsolatedQa } from "../qaReview.js";
|
|
@@ -818,7 +819,7 @@ function invalidateUnpublishedMergeDrift(projectDir, runId, ticketId, operationI
|
|
|
818
819
|
// and source edits, but retire its dispatch slot before scheduling new QA.
|
|
819
820
|
db.updateOperation(operationId, "failed", { error: error.message });
|
|
820
821
|
const paused = db.invalidateQaFinalization(runId, ticketId, head.revision, error.message, intent.branch);
|
|
821
|
-
return new Error(`${error.message}. A complete QA recheck is required. Resume with:
|
|
822
|
+
return new Error(`${error.message}. A complete QA recheck is required. Resume with: ${formatRecoveryCommand(projectDir)}`);
|
|
822
823
|
}
|
|
823
824
|
catch {
|
|
824
825
|
return error;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { ProjectLease } from "./workflowDb.js";
|
|
2
|
+
import Database from "better-sqlite3";
|
|
3
|
+
export declare class BuildOwnershipError extends Error {
|
|
4
|
+
readonly code: "stale-owner" | "unknown-owner" | "active-owner" | "invalid-launch";
|
|
5
|
+
constructor(code: "stale-owner" | "unknown-owner" | "active-owner" | "invalid-launch", message: string);
|
|
6
|
+
}
|
|
7
|
+
export interface BuildAdmission {
|
|
8
|
+
token: string;
|
|
9
|
+
runId: string;
|
|
10
|
+
project: string;
|
|
11
|
+
pid: number;
|
|
12
|
+
host: string;
|
|
13
|
+
processStart: string;
|
|
14
|
+
phase: "coordinator" | "worker";
|
|
15
|
+
heartbeatAt: string;
|
|
16
|
+
}
|
|
17
|
+
export interface BuildLaunch {
|
|
18
|
+
token: string;
|
|
19
|
+
runId: string;
|
|
20
|
+
project: string;
|
|
21
|
+
launcher: string;
|
|
22
|
+
role: BuildAdmission["phase"];
|
|
23
|
+
digest: string;
|
|
24
|
+
state: "reserved" | "dispatching" | "claimed" | "failed";
|
|
25
|
+
claimant?: BuildAdmission;
|
|
26
|
+
createdAt: string;
|
|
27
|
+
protocol?: "registered-v2";
|
|
28
|
+
child?: {
|
|
29
|
+
pid: number;
|
|
30
|
+
processStart: string;
|
|
31
|
+
host: string;
|
|
32
|
+
};
|
|
33
|
+
acknowledged?: boolean;
|
|
34
|
+
}
|
|
35
|
+
export declare function canonicalProject(project: string): string;
|
|
36
|
+
export declare function originalBuildLease(project: string): ProjectLease | undefined;
|
|
37
|
+
export declare function rememberOriginalLease(project: string, lease: ProjectLease): void;
|
|
38
|
+
export declare function forgetOriginalLease(project: string, lease: ProjectLease): void;
|
|
39
|
+
export declare function withBuildInvocation<T>(work: () => T): T;
|
|
40
|
+
export declare function localBuildAuthority(project: string): BuildAdmission | undefined;
|
|
41
|
+
export declare function rememberBuildAuthority(authority: BuildAdmission): void;
|
|
42
|
+
export declare function forgetBuildAuthority(authority: BuildAdmission): void;
|
|
43
|
+
export declare function launchDigest(args: readonly string[]): string;
|
|
44
|
+
export declare function checkBuildOwnershipSchema(db: Database.Database): void;
|
|
45
|
+
export declare function maintainBuildAdmission(authority: BuildAdmission): void;
|
|
46
|
+
export declare function stopBuildAdmissionHeartbeat(authority: BuildAdmission): void;
|
|
47
|
+
/** Additive, transactional storage. Readers never invoke this migration. */
|
|
48
|
+
export declare function migrateBuildAdmission(db: Database.Database): void;
|
|
49
|
+
export declare function readAdmission(db: Database.Database): BuildAdmission | undefined;
|
|
50
|
+
export declare function assertAdmission(db: Database.Database, authority: BuildAdmission): void;
|
|
51
|
+
export declare function acquireAdmission(db: Database.Database, project: string, runId: string, phase: BuildAdmission["phase"]): BuildAdmission;
|
|
52
|
+
export declare function releaseAdmission(db: Database.Database, authority: BuildAdmission): void;
|
|
53
|
+
export declare function reserveLaunch(db: Database.Database, authority: BuildAdmission, role: BuildAdmission["phase"], digest: string, protocol?: BuildLaunch["protocol"]): BuildLaunch;
|
|
54
|
+
export declare function readLaunch(db: Database.Database, token: string): BuildLaunch | undefined;
|
|
55
|
+
export declare function setLaunchState(db: Database.Database, authority: BuildAdmission, token: string, state: "dispatching" | "failed"): void;
|
|
56
|
+
export declare function claimLaunch(db: Database.Database, project: string, runId: string, token: string, role: BuildAdmission["phase"], digest: string): BuildAdmission;
|
|
57
|
+
/** Child registration grants no mutation/provider authority. Competes atomically with retirement. */
|
|
58
|
+
export declare function registerLaunchChild(db: Database.Database, token: string, project: string): void;
|
|
59
|
+
export declare function acknowledgeLaunchChild(db: Database.Database, authority: BuildAdmission, token: string, pid: number): void;
|
|
60
|
+
/** Restricted retirement only; does not grant execution authority or replay work. */
|
|
61
|
+
export declare function reconcileLaunch(db: Database.Database, token: string, original?: BuildAdmission): "retired" | "claimed" | "unknown";
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import { realpathSync } from "node:fs";
|
|
4
|
+
import { hostname } from "node:os";
|
|
5
|
+
import { join, resolve } from "node:path";
|
|
6
|
+
import Database from "better-sqlite3";
|
|
7
|
+
import { classifyProcess, processStartIdentity } from "./processIdentity.js";
|
|
8
|
+
export class BuildOwnershipError extends Error {
|
|
9
|
+
code;
|
|
10
|
+
constructor(code, message) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.code = code;
|
|
13
|
+
this.name = "BuildOwnershipError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export function canonicalProject(project) { return realpathSync(resolve(project)); }
|
|
17
|
+
const localAuthorities = new Map();
|
|
18
|
+
const invocation = new AsyncLocalStorage();
|
|
19
|
+
const localLeases = new Map();
|
|
20
|
+
export function originalBuildLease(project) { return (invocation.getStore()?.leases ?? (invocation.getStore() ? new Map() : localLeases)).get(canonicalProject(project)); }
|
|
21
|
+
export function rememberOriginalLease(project, lease) { const scope = invocation.getStore(); const leases = scope ? (scope.leases ??= new Map()) : localLeases; leases.set(canonicalProject(project), lease); }
|
|
22
|
+
export function forgetOriginalLease(project, lease) { const leases = invocation.getStore()?.leases ?? localLeases; if (leases.get(canonicalProject(project))?.owner === lease.owner)
|
|
23
|
+
leases.delete(canonicalProject(project)); }
|
|
24
|
+
export function withBuildInvocation(work) { return invocation.run({}, work); }
|
|
25
|
+
export function localBuildAuthority(project) { const scope = invocation.getStore(); const authority = scope ? scope.authority : localAuthorities.get(canonicalProject(project)); return authority?.project === canonicalProject(project) ? authority : undefined; }
|
|
26
|
+
export function rememberBuildAuthority(authority) { const scope = invocation.getStore(); if (scope)
|
|
27
|
+
scope.authority = authority;
|
|
28
|
+
else
|
|
29
|
+
localAuthorities.set(authority.project, authority); }
|
|
30
|
+
export function forgetBuildAuthority(authority) { const scope = invocation.getStore(); if (scope?.authority?.token === authority.token)
|
|
31
|
+
delete scope.authority; if (!scope && localAuthorities.get(authority.project)?.token === authority.token)
|
|
32
|
+
localAuthorities.delete(authority.project); }
|
|
33
|
+
export function launchDigest(args) {
|
|
34
|
+
const options = [];
|
|
35
|
+
for (let i = 2; i < args.length; i++) {
|
|
36
|
+
const option = [args[i]];
|
|
37
|
+
if (args[i + 1] !== undefined && !args[i + 1].startsWith("--"))
|
|
38
|
+
option.push(args[++i]);
|
|
39
|
+
options.push(option);
|
|
40
|
+
}
|
|
41
|
+
options.sort((a, b) => a[0].localeCompare(b[0]));
|
|
42
|
+
return createHash("sha256").update(JSON.stringify([args[0], args[1], options])).digest("hex");
|
|
43
|
+
}
|
|
44
|
+
export function checkBuildOwnershipSchema(db) {
|
|
45
|
+
const exists = db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='build_ownership_schema'").get();
|
|
46
|
+
if (!exists)
|
|
47
|
+
return;
|
|
48
|
+
const row = db.prepare("SELECT version FROM build_ownership_schema WHERE singleton=1").get();
|
|
49
|
+
if (!row || ![1, 2, 3].includes(row.version))
|
|
50
|
+
throw new Error(`Unsupported build ownership schema ${row?.version ?? "missing"}; upgrade Rafi before accessing it`);
|
|
51
|
+
}
|
|
52
|
+
const ownershipHeartbeats = new Map();
|
|
53
|
+
export function maintainBuildAdmission(authority) {
|
|
54
|
+
const timer = setInterval(() => {
|
|
55
|
+
let db;
|
|
56
|
+
try {
|
|
57
|
+
db = new Database(join(authority.project, ".rafi/recovery.sqlite3"), { fileMustExist: true });
|
|
58
|
+
db.function("rafi_protocol_v3", () => 1);
|
|
59
|
+
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);
|
|
60
|
+
if (changed.changes !== 1)
|
|
61
|
+
stopBuildAdmissionHeartbeat(authority);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
stopBuildAdmissionHeartbeat(authority);
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
db?.close();
|
|
68
|
+
}
|
|
69
|
+
}, 5000);
|
|
70
|
+
timer.unref();
|
|
71
|
+
ownershipHeartbeats.set(authority.token, timer);
|
|
72
|
+
}
|
|
73
|
+
export function stopBuildAdmissionHeartbeat(authority) {
|
|
74
|
+
const timer = ownershipHeartbeats.get(authority.token);
|
|
75
|
+
if (timer)
|
|
76
|
+
clearInterval(timer);
|
|
77
|
+
ownershipHeartbeats.delete(authority.token);
|
|
78
|
+
}
|
|
79
|
+
/** Additive, transactional storage. Readers never invoke this migration. */
|
|
80
|
+
export function migrateBuildAdmission(db) {
|
|
81
|
+
db.transaction(() => {
|
|
82
|
+
db.exec(`CREATE TABLE IF NOT EXISTS build_ownership_schema(singleton INTEGER PRIMARY KEY CHECK(singleton=1),version INTEGER NOT NULL);
|
|
83
|
+
INSERT OR IGNORE INTO build_ownership_schema VALUES(1,1);`);
|
|
84
|
+
const version = db.prepare("SELECT version FROM build_ownership_schema WHERE singleton=1").get().version;
|
|
85
|
+
if (![1, 2, 3].includes(version))
|
|
86
|
+
throw new Error(`Unsupported build ownership schema ${version}; upgrade Rafi before writing`);
|
|
87
|
+
if (version >= 2) {
|
|
88
|
+
migrateReadinessProtocol(db);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
db.exec(`CREATE TABLE IF NOT EXISTS workflow_lease_sequence(singleton INTEGER PRIMARY KEY CHECK(singleton=1),generation INTEGER NOT NULL);
|
|
92
|
+
INSERT OR IGNORE INTO workflow_lease_sequence VALUES(1,0);
|
|
93
|
+
CREATE TABLE IF NOT EXISTS build_runtime_runs(run_id TEXT PRIMARY KEY);
|
|
94
|
+
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));
|
|
95
|
+
CREATE TABLE IF NOT EXISTS build_admission(singleton INTEGER PRIMARY KEY CHECK(singleton=1),record_json TEXT NOT NULL);
|
|
96
|
+
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));
|
|
97
|
+
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);
|
|
98
|
+
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);
|
|
99
|
+
CREATE UNIQUE INDEX IF NOT EXISTS build_launch_exclusive ON build_launches(run_id) WHERE state IN ('reserved','dispatching');`);
|
|
100
|
+
if (!db.prepare("PRAGMA table_info(build_owned_processes)").all().some(column => column.name === "outcome_json"))
|
|
101
|
+
db.exec("ALTER TABLE build_owned_processes ADD COLUMN outcome_json TEXT");
|
|
102
|
+
db.exec(`CREATE TRIGGER IF NOT EXISTS build_lineage_project BEFORE INSERT ON build_retry_lineage
|
|
103
|
+
WHEN NEW.project<>COALESCE((SELECT json_extract(record_json,'$.project') FROM build_admission WHERE singleton=1),'')
|
|
104
|
+
BEGIN SELECT RAISE(ABORT,'Retry lineage project does not match admission'); END;
|
|
105
|
+
CREATE TRIGGER IF NOT EXISTS build_lineage_cycle BEFORE INSERT ON build_retry_lineage
|
|
106
|
+
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)
|
|
107
|
+
BEGIN SELECT RAISE(ABORT,'Retry lineage cycle is invalid'); END;
|
|
108
|
+
CREATE TRIGGER IF NOT EXISTS build_lineage_immutable BEFORE UPDATE ON build_retry_lineage
|
|
109
|
+
BEGIN SELECT RAISE(ABORT,'Retry lineage is immutable'); END;`);
|
|
110
|
+
db.prepare("UPDATE build_ownership_schema SET version=2 WHERE singleton=1").run();
|
|
111
|
+
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");
|
|
112
|
+
const qaTables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all()
|
|
113
|
+
.map(row => row.name).filter(name => /^qa_[a-z_]+$/.test(name) && db.prepare(`PRAGMA table_info(${name})`).all().some(column => column.name === "run_id"));
|
|
114
|
+
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])]) {
|
|
115
|
+
for (const action of ["INSERT", "UPDATE", "DELETE"]) {
|
|
116
|
+
const target = action === "DELETE" ? "OLD" : "NEW";
|
|
117
|
+
db.exec(`DROP TRIGGER IF EXISTS build_fence_${table}_${action.toLowerCase()};
|
|
118
|
+
CREATE TRIGGER build_fence_${table}_${action.toLowerCase()} BEFORE ${action} ON ${table}
|
|
119
|
+
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))
|
|
120
|
+
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'))))
|
|
121
|
+
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'))))
|
|
122
|
+
BEGIN SELECT RAISE(ABORT,'Build mutation rejected: original admission authority is stale or missing'); END;`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
migrateReadinessProtocol(db);
|
|
126
|
+
}).immediate();
|
|
127
|
+
}
|
|
128
|
+
export function readAdmission(db) {
|
|
129
|
+
const row = db.prepare("SELECT record_json FROM build_admission WHERE singleton=1").get();
|
|
130
|
+
return row ? JSON.parse(row.record_json) : undefined;
|
|
131
|
+
}
|
|
132
|
+
export function assertAdmission(db, authority) {
|
|
133
|
+
const held = readAdmission(db);
|
|
134
|
+
if (!held || held.token !== authority.token || held.runId !== authority.runId || held.project !== authority.project)
|
|
135
|
+
throw new BuildOwnershipError("stale-owner", "Build admission ownership changed; stale caller cannot mutate or dispatch");
|
|
136
|
+
}
|
|
137
|
+
export function acquireAdmission(db, project, runId, phase) {
|
|
138
|
+
return db.transaction(() => {
|
|
139
|
+
const held = readAdmission(db);
|
|
140
|
+
if (held) {
|
|
141
|
+
const owner = classifyProcess(held.pid, held.processStart, held.host);
|
|
142
|
+
if (owner.state !== "dead")
|
|
143
|
+
throw new BuildOwnershipError(owner.state === "live" ? "active-owner" : "unknown-owner", `Build ${held.runId} admission is ${owner.state}: ${owner.reason}`);
|
|
144
|
+
// A reserved launch cannot spawn until its owner conditionally marks it
|
|
145
|
+
// dispatching. Fence that transition before replacing a proven-dead owner.
|
|
146
|
+
for (const row of db.prepare("SELECT record_json FROM build_launches WHERE run_id=? AND state='reserved'").all(held.runId)) {
|
|
147
|
+
const launch = JSON.parse(row.record_json);
|
|
148
|
+
db.prepare("UPDATE build_launches SET state='failed',record_json=? WHERE token=?").run(JSON.stringify({ ...launch, state: "failed" }), launch.token);
|
|
149
|
+
}
|
|
150
|
+
const pending = db.prepare("SELECT 1 FROM build_launches WHERE run_id=? AND state='dispatching'").get(held.runId);
|
|
151
|
+
if (pending)
|
|
152
|
+
throw new Error(`Build ${held.runId} has an unresolved launch; reconcile its child before retrying`);
|
|
153
|
+
}
|
|
154
|
+
db.prepare("INSERT OR IGNORE INTO build_runtime_runs VALUES(?)").run(runId);
|
|
155
|
+
const authority = { token: randomUUID(), project: canonicalProject(project), runId, pid: process.pid, host: hostname(), processStart: processStartIdentity(), phase, heartbeatAt: new Date().toISOString() };
|
|
156
|
+
db.prepare("INSERT OR REPLACE INTO build_admission VALUES(1,?)").run(JSON.stringify(authority));
|
|
157
|
+
return authority;
|
|
158
|
+
}).immediate();
|
|
159
|
+
}
|
|
160
|
+
export function releaseAdmission(db, authority) {
|
|
161
|
+
db.transaction(() => {
|
|
162
|
+
assertAdmission(db, authority);
|
|
163
|
+
if (db.prepare("SELECT 1 FROM build_launches WHERE run_id=? AND state IN ('reserved','dispatching')").get(authority.runId))
|
|
164
|
+
throw new Error("Cannot release admission with an unresolved launch");
|
|
165
|
+
db.prepare("DELETE FROM build_admission WHERE singleton=1").run();
|
|
166
|
+
}).immediate();
|
|
167
|
+
}
|
|
168
|
+
export function reserveLaunch(db, authority, role, digest, protocol) {
|
|
169
|
+
return db.transaction(() => {
|
|
170
|
+
assertAdmission(db, authority);
|
|
171
|
+
const launch = { token: randomUUID(), runId: authority.runId, project: authority.project, launcher: authority.token, role, digest, protocol, state: "reserved", createdAt: new Date().toISOString() };
|
|
172
|
+
db.prepare("INSERT INTO build_launches VALUES(?,?,?,?)").run(launch.token, launch.runId, launch.state, JSON.stringify(launch));
|
|
173
|
+
return launch;
|
|
174
|
+
}).immediate();
|
|
175
|
+
}
|
|
176
|
+
export function readLaunch(db, token) {
|
|
177
|
+
const row = db.prepare("SELECT record_json FROM build_launches WHERE token=?").get(token);
|
|
178
|
+
return row ? JSON.parse(row.record_json) : undefined;
|
|
179
|
+
}
|
|
180
|
+
export function setLaunchState(db, authority, token, state) {
|
|
181
|
+
db.transaction(() => {
|
|
182
|
+
assertAdmission(db, authority);
|
|
183
|
+
const launch = readLaunch(db, token);
|
|
184
|
+
if (state === "failed" && launch?.state === "failed" && launch.launcher === authority.token)
|
|
185
|
+
return;
|
|
186
|
+
if (!launch || launch.launcher !== authority.token || !(state === "dispatching" ? launch.state === "reserved" : ["reserved", "dispatching"].includes(launch.state)))
|
|
187
|
+
throw new Error("Invalid launch transition");
|
|
188
|
+
db.prepare("UPDATE build_launches SET state=?,record_json=? WHERE token=?").run(state, JSON.stringify({ ...launch, state }), token);
|
|
189
|
+
}).immediate();
|
|
190
|
+
}
|
|
191
|
+
export function claimLaunch(db, project, runId, token, role, digest) {
|
|
192
|
+
return db.transaction(() => {
|
|
193
|
+
const launch = readLaunch(db, token);
|
|
194
|
+
const owner = readAdmission(db);
|
|
195
|
+
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())
|
|
196
|
+
throw new BuildOwnershipError("invalid-launch", "Invalid, stale, or already claimed build launch authorization");
|
|
197
|
+
if (launch.protocol === "registered-v2" && (!launch.acknowledged || launch.child?.pid !== process.pid || launch.child.processStart !== processStartIdentity() || launch.child.host !== hostname()))
|
|
198
|
+
throw new BuildOwnershipError("invalid-launch", "Build child registration was not acknowledged");
|
|
199
|
+
const authority = { ...owner, token: randomUUID(), phase: role, pid: process.pid, host: hostname(), processStart: processStartIdentity(), heartbeatAt: new Date().toISOString() };
|
|
200
|
+
db.prepare("UPDATE build_admission SET record_json=? WHERE singleton=1").run(JSON.stringify(authority));
|
|
201
|
+
db.prepare("UPDATE build_launches SET state='claimed',record_json=? WHERE token=?").run(JSON.stringify({ ...launch, state: "claimed", claimant: authority }), token);
|
|
202
|
+
return authority;
|
|
203
|
+
}).immediate();
|
|
204
|
+
}
|
|
205
|
+
/** Child registration grants no mutation/provider authority. Competes atomically with retirement. */
|
|
206
|
+
export function registerLaunchChild(db, token, project) {
|
|
207
|
+
db.transaction(() => {
|
|
208
|
+
const launch = readLaunch(db, token);
|
|
209
|
+
if (!launch || launch.state !== "dispatching" || launch.protocol !== "registered-v2" || launch.project !== canonicalProject(project) || launch.child)
|
|
210
|
+
throw new BuildOwnershipError("invalid-launch", "Launch is retired, already registered, or invalid");
|
|
211
|
+
const child = { pid: process.pid, processStart: processStartIdentity(), host: hostname() };
|
|
212
|
+
if (child.processStart === "unavailable")
|
|
213
|
+
throw new BuildOwnershipError("unknown-owner", "Cannot register child process identity");
|
|
214
|
+
db.prepare("UPDATE build_launches SET record_json=? WHERE token=?").run(JSON.stringify({ ...launch, child }), token);
|
|
215
|
+
}).immediate();
|
|
216
|
+
}
|
|
217
|
+
export function acknowledgeLaunchChild(db, authority, token, pid) {
|
|
218
|
+
db.transaction(() => {
|
|
219
|
+
assertAdmission(db, authority);
|
|
220
|
+
const launch = readLaunch(db, token);
|
|
221
|
+
if (!launch || launch.state !== "dispatching" || launch.launcher !== authority.token || launch.child?.pid !== pid || classifyProcess(pid, launch.child.processStart, launch.child.host).state !== "live")
|
|
222
|
+
throw new BuildOwnershipError("invalid-launch", "Registered child cannot be acknowledged");
|
|
223
|
+
db.prepare("UPDATE build_launches SET record_json=? WHERE token=?").run(JSON.stringify({ ...launch, acknowledged: true }), token);
|
|
224
|
+
}).immediate();
|
|
225
|
+
}
|
|
226
|
+
/** Restricted retirement only; does not grant execution authority or replay work. */
|
|
227
|
+
export function reconcileLaunch(db, token, original) {
|
|
228
|
+
return db.transaction(() => {
|
|
229
|
+
const launch = readLaunch(db, token);
|
|
230
|
+
if (!launch || launch.state === "failed")
|
|
231
|
+
return "retired";
|
|
232
|
+
if (launch.state === "claimed")
|
|
233
|
+
return "claimed";
|
|
234
|
+
const owner = readAdmission(db);
|
|
235
|
+
if (!owner || owner.token !== launch.launcher)
|
|
236
|
+
return "unknown";
|
|
237
|
+
const callerOwns = original?.token === owner.token;
|
|
238
|
+
if (!callerOwns && classifyProcess(owner.pid, owner.processStart, owner.host).state !== "dead")
|
|
239
|
+
return "unknown";
|
|
240
|
+
// New gated children cannot enter the CLI until durable acknowledgement.
|
|
241
|
+
// Fence the token even if a delayed bootstrap is still registering.
|
|
242
|
+
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")));
|
|
243
|
+
if (!safe)
|
|
244
|
+
return "unknown";
|
|
245
|
+
db.prepare("UPDATE build_launches SET state='failed',record_json=? WHERE token=?").run(JSON.stringify({ ...launch, state: "failed" }), token);
|
|
246
|
+
return "retired";
|
|
247
|
+
}).immediate();
|
|
248
|
+
}
|
|
249
|
+
/** Old open connections lack this function, even if they passed a prior schema check. */
|
|
250
|
+
function migrateReadinessProtocol(db) {
|
|
251
|
+
const version = db.prepare("SELECT version FROM build_ownership_schema").get().version;
|
|
252
|
+
if (version === 3)
|
|
253
|
+
return;
|
|
254
|
+
const owner = readAdmission(db);
|
|
255
|
+
// Leave legacy inspection/restricted cleanup available. New execution requires v3.
|
|
256
|
+
if (owner && classifyProcess(owner.pid, owner.processStart, owner.host).state !== "dead")
|
|
257
|
+
return;
|
|
258
|
+
const lease = db.prepare("SELECT pid,process_start,host FROM project_lease").get();
|
|
259
|
+
if (lease && classifyProcess(lease.pid, lease.process_start, lease.host).state !== "dead")
|
|
260
|
+
return;
|
|
261
|
+
for (const row of db.prepare("SELECT state_json FROM supervisor_leases WHERE status IN ('running','stopping')").all()) {
|
|
262
|
+
const state = JSON.parse(row.state_json);
|
|
263
|
+
if (!state.pid || classifyProcess(state.pid, state.processStart).state !== "dead")
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
for (const row of db.prepare("SELECT record_json FROM build_launches WHERE state IN ('reserved','dispatching')").all()) {
|
|
267
|
+
const launch = JSON.parse(row.record_json);
|
|
268
|
+
if (reconcileLaunch(db, launch.token) === "unknown")
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
// Local executable ownership must be reconciled before semantic migration.
|
|
272
|
+
if (db.prepare("SELECT 1 FROM build_owned_processes WHERE state<>'quiescent' LIMIT 1").get())
|
|
273
|
+
return;
|
|
274
|
+
// Remote uncertainty survives migration unchanged. Recovery needs the new
|
|
275
|
+
// writer fences to reconcile it; ordinary admission still rejects dispatch uncertainty.
|
|
276
|
+
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").all();
|
|
277
|
+
for (const { name } of tables) {
|
|
278
|
+
if (!/^[a-z_]+$/.test(name))
|
|
279
|
+
throw new Error("Unexpected ownership table");
|
|
280
|
+
for (const action of ["INSERT", "UPDATE", "DELETE"])
|
|
281
|
+
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`);
|
|
282
|
+
}
|
|
283
|
+
db.prepare("UPDATE build_ownership_schema SET version=3").run();
|
|
284
|
+
}
|
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 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** Minimal pre-CLI child. No providers or mutable workflow code before acknowledgement. */
|
|
2
|
+
import { WorkflowDb } from "./workflowDb.js";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
const [entry, project, token, ...args] = process.argv.slice(2);
|
|
5
|
+
if (!entry || !project || !token || !process.send || !process.connected)
|
|
6
|
+
throw new Error("Missing resume startup channel");
|
|
7
|
+
const db = new WorkflowDb(project);
|
|
8
|
+
try {
|
|
9
|
+
db.registerBuildLaunchChild(token);
|
|
10
|
+
}
|
|
11
|
+
finally {
|
|
12
|
+
db.close();
|
|
13
|
+
}
|
|
14
|
+
await new Promise((resolve, reject) => {
|
|
15
|
+
const timeout = setTimeout(() => finish(new Error("Resume startup acknowledgement timed out")), 10_000);
|
|
16
|
+
const disconnected = () => finish(new Error("Resume launcher exited before acknowledgement"));
|
|
17
|
+
const message = (value) => { if (value === "rafi-launch-ack")
|
|
18
|
+
finish(); };
|
|
19
|
+
function finish(error) {
|
|
20
|
+
clearTimeout(timeout);
|
|
21
|
+
process.off("disconnect", disconnected);
|
|
22
|
+
process.off("message", message);
|
|
23
|
+
if (error)
|
|
24
|
+
reject(error);
|
|
25
|
+
else
|
|
26
|
+
resolve();
|
|
27
|
+
}
|
|
28
|
+
process.once("disconnect", disconnected);
|
|
29
|
+
process.on("message", message);
|
|
30
|
+
process.send({ kind: "rafi-launch-registered", token });
|
|
31
|
+
});
|
|
32
|
+
// The durable claim still validates the token atomically after acknowledgement.
|
|
33
|
+
if (!process.env.RAFI_BUILD_WORKER_RUN)
|
|
34
|
+
process.disconnect();
|
|
35
|
+
process.argv = [process.execPath, entry, ...args];
|
|
36
|
+
await import(pathToFileURL(entry).href);
|
package/dist/buildRuns.d.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
+
export { withBuildInvocation as withBuildRecoveryInvocation, launchDigest as launchArgumentDigest } from "./buildAdmission.js";
|
|
1
2
|
import type { AutonomyProfile, BuildRunRecordV2, ProviderSessionRefV1, ResolvedAgentSettings, ResolvedAutonomyPolicy, SessionAvailabilityV1 } from "rafi-spec";
|
|
3
|
+
import { type ProjectLease } from "./workflowDb.js";
|
|
2
4
|
import { type ResolveSessionAvailabilityOptions } from "./sessionAvailability.js";
|
|
3
5
|
export declare const BUILD_RUN_VERSION = 3;
|
|
4
6
|
export declare const BUILD_RUN_DIRECTORY = ".foreman/runs";
|
|
5
7
|
export declare const BUILD_HEARTBEAT_MS = 10000;
|
|
6
8
|
export declare const BUILD_LEASE_STALE_MS = 45000;
|
|
9
|
+
export declare function bindBuildAuthority(run: BuildRunRecordV2, lease: ProjectLease): BuildRunRecordV2;
|
|
7
10
|
export interface CreateBuildRunInput {
|
|
8
11
|
runId?: string;
|
|
9
12
|
tickets: string[];
|
|
@@ -44,9 +47,14 @@ export declare function recordBuildReceipt(projectDir: string, run: BuildRunReco
|
|
|
44
47
|
export declare function heartbeatBuildRun(projectDir: string, run: BuildRunRecordV2, now?: Date): BuildRunRecordV2;
|
|
45
48
|
export declare function releaseBuildLease(projectDir: string, run: BuildRunRecordV2, status?: BuildRunRecordV2["status"]): BuildRunRecordV2;
|
|
46
49
|
export declare function completeBuildRun(projectDir: string, run: BuildRunRecordV2, now?: Date): BuildRunRecordV2;
|
|
50
|
+
/** A recovered ticket boundary is complete only when the saved run scope is done. */
|
|
51
|
+
export declare function finishRecoveredTicketScope(projectDir: string, run: BuildRunRecordV2, checkpoint: string): BuildRunRecordV2;
|
|
47
52
|
export declare function readBuildRuns(projectDir: string): BuildRunRecordV2[];
|
|
48
53
|
export declare function recoverableBuildRuns(projectDir: string, now?: Date): Array<BuildRunRecordV2 & {
|
|
49
54
|
active: boolean;
|
|
55
|
+
ownership: "live" | "dead" | "unknown";
|
|
56
|
+
ownershipReason: string;
|
|
57
|
+
cleanupOnly?: boolean;
|
|
50
58
|
}>;
|
|
51
59
|
export declare function isLeaseActive(run: BuildRunRecordV2, now?: Date): boolean;
|
|
52
60
|
export declare function buildRecoveryPreview(run: BuildRunRecordV2): string[];
|
|
@@ -74,7 +82,16 @@ export interface BuildRecoveryProjection {
|
|
|
74
82
|
sessionCandidateRef?: ProviderSessionRefV1;
|
|
75
83
|
sessionAvailability?: SessionAvailabilityV1;
|
|
76
84
|
}
|
|
85
|
+
/** Saved context may be broader than the work authorized for this invocation. */
|
|
86
|
+
export declare function recoveryExecutionTickets(run: BuildRunRecordV2, requested: readonly string[], frozen: boolean): string[];
|
|
87
|
+
/** Read-only reconciliation also handles crashes before the last progress checkpoint. */
|
|
88
|
+
export declare function currentBranchTicketProgress(projectDir: string, run: BuildRunRecordV2): {
|
|
89
|
+
completed: string[];
|
|
90
|
+
remaining: string[];
|
|
91
|
+
pendingQa: string[];
|
|
92
|
+
} | undefined;
|
|
93
|
+
/** Match the frozen preview at both the resume wrapper and direct start boundary. */
|
|
94
|
+
export declare function currentBranchRecoveryTicket(projectDir: string, run: BuildRunRecordV2): string | undefined;
|
|
77
95
|
export declare function projectBuildRecovery(projectDir: string, run: BuildRunRecordV2, now?: Date, ticketOverride?: string, sessionAvailability?: SessionAvailabilityV1): BuildRecoveryProjection;
|
|
78
96
|
export declare function resolveBuildRecoveryProjection(projectDir: string, run: BuildRunRecordV2, now?: Date, ticketOverride?: string, probe?: ResolveSessionAvailabilityOptions): Promise<BuildRecoveryProjection>;
|
|
79
97
|
export declare function formatBuildRecoveryProjection(projection: BuildRecoveryProjection): string[];
|
|
80
|
-
export {};
|