ai-foreman 1.7.28 → 1.7.29
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/dist/adapters/claude.js +1 -1
- package/dist/adapters/types.d.ts +1 -0
- package/dist/buildApproval.d.ts +15 -0
- package/dist/buildApproval.js +43 -0
- package/dist/cli/start.js +13 -1
- package/dist/cli/tickets.d.ts +7 -0
- package/dist/cli/tickets.js +18 -3
- package/dist/foreman.d.ts +11 -2
- package/dist/foreman.js +22 -10
- package/dist/hostGraphApplicability.d.ts +45 -0
- package/dist/hostGraphApplicability.js +99 -0
- package/dist/hostQaAugmentation.d.ts +18 -0
- package/dist/hostQaAugmentation.js +64 -0
- package/dist/hostQaComparison.js +2 -1
- package/dist/qaBuildGate.js +3 -2
- package/dist/qaContractCoverage.js +12 -3
- package/dist/qaEffectiveConfig.d.ts +1 -0
- package/dist/qaEffectiveConfig.js +6 -1
- package/dist/qaPreparation.js +21 -2
- package/dist/qaPreparationStore.js +34 -6
- package/dist/qaReview.js +18 -5
- package/dist/qaToolRecovery.d.ts +12 -0
- package/dist/qaToolRecovery.js +44 -0
- package/dist/qaVerificationContract.d.ts +2 -0
- package/dist/qaVerificationContract.js +37 -2
- package/dist/supervisedStart.d.ts +1 -1
- package/dist/supervisedStart.js +44 -11
- package/dist/supervisor.d.ts +18 -2
- package/dist/supervisor.js +74 -17
- package/dist/workflowDb.js +28 -6
- package/package.json +3 -3
|
@@ -6,7 +6,12 @@ import { loadTicketSetupConfigWithDefaults, loadRafiConfigObject } from "./ticke
|
|
|
6
6
|
import { resolveQaPreparationConfig } from "./qaPreparationPolicy.js";
|
|
7
7
|
import { contractDigest } from "./qaVerificationContract.js";
|
|
8
8
|
export const FINAL_QA_ROLE_SUFFIX = "You are an independent QA reviewer. Do not edit source, tickets, configuration, or project documentation. You may run tests and create only harmless ignored caches or coverage output.";
|
|
9
|
-
export
|
|
9
|
+
export const QA_COMMAND_DISCIPLINE = [
|
|
10
|
+
"QA command discipline: issue one shell command per Bash call. Prefer the Read tool for source inspection and the configured project test command for verification.",
|
|
11
|
+
"Do not use shell sequencing or scaffolding such as ;, ||, redirects, command substitution, echo exit markers, temporary baseline copies, cp, cmp, or mkdir. Do not reconstruct or compare preimplementation files; Rafi owns baseline, path-scope, unchanged-file, and byte-identity comparisons.",
|
|
12
|
+
"If a command is denied by policy, do not retry the same denied command or wrap it in another compound command. Use a permitted equivalent that tests the same product behavior and record any limitation truthfully.",
|
|
13
|
+
].join(" ");
|
|
14
|
+
export function finalQaRoleInstructions(rules) { return `${rules}\n\n${QA_COMMAND_DISCIPLINE}\n\n${FINAL_QA_ROLE_SUFFIX}`; }
|
|
10
15
|
export function loadQaPreparationPolicy(projectDir) {
|
|
11
16
|
const config = loadRafiConfigObject(projectDir);
|
|
12
17
|
return resolveQaPreparationConfig(config?.qa_preparation, config?.qa_preparation_shadow === true, config?.qa_preparation_wall_time_ms);
|
package/dist/qaPreparation.js
CHANGED
|
@@ -44,12 +44,26 @@ export function normalizeExpectedFutureAssessment(assessment, candidate) {
|
|
|
44
44
|
const copy = structuredClone(assessment);
|
|
45
45
|
const checks = new Map(candidate.checks.map(check => [check.id, check]));
|
|
46
46
|
copy.concerns = copy.concerns.map(concern => {
|
|
47
|
+
// A provider can report a stale check identity copied into descriptive
|
|
48
|
+
// prose from an earlier draft. The host has already validated the
|
|
49
|
+
// structured check/coverage/dependency references; when the assessor also
|
|
50
|
+
// says the referenced acceptance item is already covered, this is a
|
|
51
|
+
// non-material labeling defect rather than an unresolved contract gap.
|
|
52
|
+
// Keep the concern in the retained assessment, but do not block Builder.
|
|
53
|
+
const staleCoveredReference = /not in the declared check IDs or its dependsOnChecks/i.test(concern.reason)
|
|
54
|
+
&& /reference can be removed|already covered/i.test(`${concern.reason} ${concern.nextAction}`)
|
|
55
|
+
&& concern.checkIds.length > 0
|
|
56
|
+
&& concern.checkIds.every(id => checks.has(id));
|
|
57
|
+
if (concern.material && staleCoveredReference)
|
|
58
|
+
return { ...concern, material: false };
|
|
47
59
|
if (!concern.material || !concern.checkIds.length || !concern.checkIds.every(id => checks.get(id)?.timing === "postimplementation"))
|
|
48
60
|
return concern;
|
|
49
61
|
const expectedFutureAbsence = /\b(absent|missing|not (?:yet )?(?:implemented|present|available)|could not find|not found|does not exist|fails? with)\b/i.test(concern.reason)
|
|
50
62
|
&& /\b(?:before|pre[- ]?builder|not yet)\b/i.test(concern.reason)
|
|
51
63
|
&& /\b(?:implement|add|create|rerun|re-?run|builder|after implementation)\b/i.test(concern.nextAction);
|
|
52
|
-
|
|
64
|
+
const inventedSiblingAbsence = /\b(?:no|without|absen[ct]|missing)\s+(?:the\s+)?[a-z0-9_-]+\s+export\b|\b(?:sibling|other)\s+(?:feature|slice)\s+(?:absen[ct]|removed|present)\b/i.test(concern.reason)
|
|
65
|
+
&& /\b(?:own|imports? only|no [a-z0-9_-]+ symbol|drop|remove)\b/i.test(concern.nextAction);
|
|
66
|
+
return expectedFutureAbsence || inventedSiblingAbsence ? { ...concern, material: false } : concern;
|
|
53
67
|
});
|
|
54
68
|
return copy;
|
|
55
69
|
}
|
|
@@ -411,7 +425,7 @@ export async function prepareVerificationContract(initial, inventory, deps) {
|
|
|
411
425
|
normalized.assessedRequirementIds = [...fallback.assessedRequirementIds];
|
|
412
426
|
return normalized;
|
|
413
427
|
};
|
|
414
|
-
let assessmentResponse = retainedAssessment?.draftPayloadDigest === draftDigest(candidate) ? undefined : await run("assess", "assessment", `Independent semantic assessment in a fresh conversation. Assess each original requirement and selected-depth obligation; identify omissions, conflicts, practical verification gaps, accidental scope, and unsupported dispositions. Treat Rafi-owned bookkeeping paths (.tickets, .rafi, generated docs/configuration, and agent instructions) as expected host changes. Scope concerns must be evaluated against approved product paths and ticket requirements; do not require an exact full-worktree status listing. Future Builder actions are not preparation failures: when an approved requirement specifies test-first ordering, a migration, rollback rehearsal, or other implementation-time sequence, represent it as a mandatory postimplementation check requiring retained Builder evidence. Do not mark the absence of that future evidence before Builder starts as a material concern, and do not demand that preparation reproduce historical order. Only report a material concern when the contract cannot state an actionable postimplementation check or when an already-required preimplementation fact is missing. Return RAFI_QA_ASSESSMENT_START/END with QaSemanticAssessmentV1 (schema: ${JSON.stringify(qaPreparationDepthDecisionV1Schema)}). The concerns field is an array of objects, never strings; every concern object must have exactly these six fields and no others: {reason:string, requirementIds:string[], checkIds:string[], material:boolean, owner:string, nextAction:string}. Do not use legacy fields such as severity, evidence, title, message, category, or resolution. Use concerns: [] only when all issues are resolved. assessedRequirementIds MUST contain every ID in this exact list, even when a requirement is host-owned or has no concern: ${JSON.stringify(candidate.requirements.map(requirement => requirement.id))}. Put every individually assessed required obligation label in assessedObligations, using the exact labels below; do not combine labels or claim to have assessed anything you did not assess. Required obligation labels: ${JSON.stringify(depthObligations(candidate.depthDecision.level))}. Bind exactly ${JSON.stringify(binding)}.\nPrior independent concerns requiring concrete resolution evidence: ${JSON.stringify(targetedConcerns)}\nOriginal inventory:\n${JSON.stringify(inventory)}\nDraft:\n${JSON.stringify(candidate)}`);
|
|
428
|
+
let assessmentResponse = retainedAssessment?.draftPayloadDigest === draftDigest(candidate) ? undefined : await run("assess", "assessment", `Independent semantic assessment in a fresh conversation. Assess each original requirement and selected-depth obligation; identify omissions, conflicts, practical verification gaps, accidental scope, and unsupported dispositions. Treat Rafi-owned bookkeeping paths (.tickets, .rafi, generated docs/configuration, and agent instructions) as expected host changes. Scope concerns must be evaluated against approved product paths and ticket requirements; do not require an exact full-worktree status listing. Future Builder actions are not preparation failures: when an approved requirement specifies test-first ordering, a migration, rollback rehearsal, or other implementation-time sequence, represent it as a mandatory postimplementation check requiring retained Builder evidence. Do not mark the absence of that future evidence before Builder starts as a material concern, and do not demand that preparation reproduce historical order. If a concern's only next action is to inspect, compare, run, or otherwise produce evidence for a check that is already declared with postimplementation timing, set that concern material:false; it is an actionable future check, not a preparation blocker. Only report a material concern when the contract cannot state an actionable postimplementation check or when an already-required preimplementation fact is missing. Return RAFI_QA_ASSESSMENT_START/END with QaSemanticAssessmentV1 (schema: ${JSON.stringify(qaPreparationDepthDecisionV1Schema)}). The concerns field is an array of objects, never strings; every concern object must have exactly these six fields and no others: {reason:string, requirementIds:string[], checkIds:string[], material:boolean, owner:string, nextAction:string}. Do not use legacy fields such as severity, evidence, title, message, category, or resolution. Use concerns: [] only when all issues are resolved. assessedRequirementIds MUST contain every ID in this exact list, even when a requirement is host-owned or has no concern: ${JSON.stringify(candidate.requirements.map(requirement => requirement.id))}. Put every individually assessed required obligation label in assessedObligations, using the exact labels below; do not combine labels or claim to have assessed anything you did not assess. Required obligation labels: ${JSON.stringify(depthObligations(candidate.depthDecision.level))}. Bind exactly ${JSON.stringify(binding)}.\nPrior independent concerns requiring concrete resolution evidence: ${JSON.stringify(targetedConcerns)}\nOriginal inventory:\n${JSON.stringify(inventory)}\nDraft:\n${JSON.stringify(candidate)}`);
|
|
415
429
|
let assessed = assessmentResponse
|
|
416
430
|
? await parsedContract(assessmentResponse, "RAFI_QA_ASSESSMENT", validateAssessmentContract, "QA semantic assessment", (value, response) => normalizeAssessment(value, response))
|
|
417
431
|
: { value: retainedAssessment, response: undefined };
|
|
@@ -419,6 +433,11 @@ export async function prepareVerificationContract(initial, inventory, deps) {
|
|
|
419
433
|
// session identity, but it cannot prove or safely author the builder's ID.
|
|
420
434
|
assessed.value.authorSessionId = binding.authorSessionId;
|
|
421
435
|
for (let repair = 0; assessmentResponse && repair < 3; repair++) {
|
|
436
|
+
// Apply host-owned current-branch applicability normalization before
|
|
437
|
+
// semantic validation. Otherwise an assessor's material sibling-absence
|
|
438
|
+
// concern is rejected before the host can downgrade that non-binding
|
|
439
|
+
// concern to an auditable, non-material observation.
|
|
440
|
+
assessed.value = normalizeExpectedFutureAssessment(assessed.value, candidate);
|
|
422
441
|
const semanticIssues = validateAssessment(candidate, assessed.value, authorSessionId);
|
|
423
442
|
if (!semanticIssues.length)
|
|
424
443
|
break;
|
|
@@ -9,7 +9,7 @@ export function migrateQaPreparation(db) {
|
|
|
9
9
|
db.exec(`
|
|
10
10
|
CREATE TABLE IF NOT EXISTS qa_preparation_policy(run_id TEXT PRIMARY KEY REFERENCES workflow_runs(run_id),record_json TEXT NOT NULL);
|
|
11
11
|
CREATE TABLE IF NOT EXISTS qa_preparation_budgets(id TEXT PRIMARY KEY,run_id TEXT NOT NULL REFERENCES workflow_runs(run_id),work_id TEXT NOT NULL,admission_digest TEXT NOT NULL,record_json TEXT NOT NULL,UNIQUE(run_id,work_id,admission_digest));
|
|
12
|
-
CREATE TABLE IF NOT EXISTS qa_depth_decisions(decision_id TEXT
|
|
12
|
+
CREATE TABLE IF NOT EXISTS qa_depth_decisions(decision_id TEXT NOT NULL,run_id TEXT NOT NULL REFERENCES workflow_runs(run_id),work_id TEXT NOT NULL,admission_digest TEXT NOT NULL,record_json TEXT NOT NULL,PRIMARY KEY(decision_id,run_id,work_id,admission_digest));
|
|
13
13
|
CREATE TABLE IF NOT EXISTS qa_preparation_attempts(operation_id TEXT PRIMARY KEY,budget_id TEXT NOT NULL REFERENCES qa_preparation_budgets(id),state TEXT NOT NULL,intent_json TEXT NOT NULL,result_json TEXT);
|
|
14
14
|
CREATE TABLE IF NOT EXISTS qa_preparation_progress(budget_id TEXT NOT NULL REFERENCES qa_preparation_budgets(id),stage TEXT NOT NULL,digest TEXT NOT NULL,PRIMARY KEY(budget_id,stage));
|
|
15
15
|
CREATE TABLE IF NOT EXISTS qa_verification_contracts(digest TEXT PRIMARY KEY,run_id TEXT NOT NULL REFERENCES workflow_runs(run_id),work_id TEXT NOT NULL,admission_digest TEXT NOT NULL,revision INTEGER NOT NULL,record_json TEXT NOT NULL,UNIQUE(run_id,work_id,admission_digest,revision));
|
|
@@ -19,6 +19,7 @@ export function migrateQaPreparation(db) {
|
|
|
19
19
|
CREATE TABLE IF NOT EXISTS qa_contract_artifacts(digest TEXT PRIMARY KEY,kind TEXT NOT NULL,record_json TEXT NOT NULL);
|
|
20
20
|
CREATE TABLE IF NOT EXISTS qa_preparation_events(event_id TEXT PRIMARY KEY,run_id TEXT NOT NULL REFERENCES workflow_runs(run_id),work_id TEXT NOT NULL,kind TEXT NOT NULL,record_json TEXT NOT NULL);
|
|
21
21
|
`);
|
|
22
|
+
migrateQaDepthDecisions(db);
|
|
22
23
|
for (const table of ["qa_preparation_policy", "qa_depth_decisions", "qa_verification_contracts", "qa_contract_check_history", "qa_contract_receipts", "qa_contract_artifacts", "qa_preparation_events"]) {
|
|
23
24
|
for (const action of ["UPDATE", "DELETE"])
|
|
24
25
|
db.exec(`CREATE TRIGGER IF NOT EXISTS preparation_immutable_${table}_${action} BEFORE ${action} ON ${table} BEGIN SELECT RAISE(ABORT,'Preparation authority and evidence are immutable'); END`);
|
|
@@ -33,6 +34,35 @@ export function migrateQaPreparation(db) {
|
|
|
33
34
|
db.exec(`CREATE TRIGGER IF NOT EXISTS preparation_owner_${table}_${action} BEFORE ${action} ON ${table} BEGIN SELECT CASE WHEN rafi_qa_preparation_owner(${expression})<>1 THEN RAISE(ABORT,'Original preparation owner required') END; END`);
|
|
34
35
|
}
|
|
35
36
|
}
|
|
37
|
+
/** Provider decision ids are scoped to the admitted preparation, not global. */
|
|
38
|
+
function migrateQaDepthDecisions(db) {
|
|
39
|
+
const columns = db.prepare("PRAGMA table_info(qa_depth_decisions)").all();
|
|
40
|
+
if (!columns.length || columns.some(column => column.name === "admission_digest"))
|
|
41
|
+
return;
|
|
42
|
+
db.transaction(() => {
|
|
43
|
+
for (const action of ["INSERT", "UPDATE", "DELETE"]) {
|
|
44
|
+
db.exec(`DROP TRIGGER IF EXISTS preparation_immutable_qa_depth_decisions_${action}`);
|
|
45
|
+
db.exec(`DROP TRIGGER IF EXISTS preparation_owner_qa_depth_decisions_${action}`);
|
|
46
|
+
db.exec(`DROP TRIGGER IF EXISTS preparation_lease_qa_depth_decisions_${action}`);
|
|
47
|
+
}
|
|
48
|
+
db.exec("CREATE TABLE qa_depth_decisions_scoped(decision_id TEXT NOT NULL,run_id TEXT NOT NULL REFERENCES workflow_runs(run_id),work_id TEXT NOT NULL,admission_digest TEXT NOT NULL,record_json TEXT NOT NULL,PRIMARY KEY(decision_id,run_id,work_id,admission_digest))");
|
|
49
|
+
db.exec("INSERT INTO qa_depth_decisions_scoped(decision_id,run_id,work_id,admission_digest,record_json) SELECT decision_id,run_id,work_id,COALESCE(NULLIF(json_extract(record_json,'$.admissionDigest'),''),'legacy:' || run_id || ':' || work_id),record_json FROM qa_depth_decisions");
|
|
50
|
+
db.exec("DROP TABLE qa_depth_decisions");
|
|
51
|
+
db.exec("ALTER TABLE qa_depth_decisions_scoped RENAME TO qa_depth_decisions");
|
|
52
|
+
for (const action of ["INSERT", "UPDATE", "DELETE"]) {
|
|
53
|
+
const row = action === "DELETE" ? "OLD" : "NEW";
|
|
54
|
+
db.exec(`CREATE TRIGGER preparation_immutable_qa_depth_decisions_${action} BEFORE ${action} ON qa_depth_decisions BEGIN SELECT RAISE(ABORT,'Preparation authority and evidence are immutable'); END`);
|
|
55
|
+
const ownerRow = action === "DELETE" ? "OLD" : "NEW";
|
|
56
|
+
db.exec(`CREATE TRIGGER preparation_owner_qa_depth_decisions_${action} BEFORE ${action} ON qa_depth_decisions BEGIN SELECT CASE WHEN rafi_qa_preparation_owner(${ownerRow}.run_id)<>1 THEN RAISE(ABORT,'Original preparation owner required') END; END`);
|
|
57
|
+
const leaseRun = ownerRow;
|
|
58
|
+
db.exec(`CREATE TRIGGER preparation_lease_qa_depth_decisions_${action} BEFORE ${action} ON qa_depth_decisions
|
|
59
|
+
WHEN (EXISTS(SELECT 1 FROM build_admission) OR rafi_build_writer_run()<>'' OR EXISTS(SELECT 1 FROM build_runtime_runs WHERE run_id=${leaseRun}.run_id))
|
|
60
|
+
AND NOT EXISTS(SELECT 1 FROM build_admission WHERE json_extract(record_json,'$.token')=rafi_build_writer_token() AND (json_extract(record_json,'$.runId')=${leaseRun}.run_id OR EXISTS(SELECT 1 FROM build_child_runs WHERE child=${leaseRun}.run_id AND parent=json_extract(record_json,'$.runId'))))
|
|
61
|
+
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=${leaseRun}.run_id))
|
|
62
|
+
BEGIN SELECT RAISE(ABORT,'Preparation requires the original durable lease generation'); END`);
|
|
63
|
+
}
|
|
64
|
+
})();
|
|
65
|
+
}
|
|
36
66
|
/** Tagged enforcing authority also fences older callers that omit new fields. */
|
|
37
67
|
export function migrateQaPreparationGuards(db) {
|
|
38
68
|
const scopes = {
|
|
@@ -242,13 +272,11 @@ export class QaPreparationStore {
|
|
|
242
272
|
current.level = decision.level;
|
|
243
273
|
current.deadlineMs = Math.max(current.deadlineMs, current.startMs + current.policy.wallTimeMs[decision.level - 1]);
|
|
244
274
|
this.saveBudget(current);
|
|
245
|
-
const
|
|
246
|
-
const
|
|
247
|
-
if (priorScope && (priorScope.run_id !== budget.runId || priorScope.work_id !== budget.workId))
|
|
248
|
-
throw new Error("Planner depth identity belongs to another admitted work");
|
|
275
|
+
const priorRow = this.db.prepare("SELECT record_json FROM qa_depth_decisions WHERE decision_id=? AND run_id=? AND work_id=? AND admission_digest=?").get(decision.decisionId, budget.runId, budget.workId, budget.admissionDigest);
|
|
276
|
+
const prior = priorRow ? this.decode(priorRow.record_json) : undefined;
|
|
249
277
|
if (prior && this.protectedJson(prior) !== this.protectedJson(decision))
|
|
250
278
|
throw new Error("Conflicting planner depth identity");
|
|
251
|
-
this.db.prepare("INSERT INTO qa_depth_decisions VALUES(
|
|
279
|
+
this.db.prepare("INSERT INTO qa_depth_decisions VALUES(?,?,?,?,?) ON CONFLICT(decision_id,run_id,work_id,admission_digest) DO NOTHING").run(decision.decisionId, budget.runId, budget.workId, budget.admissionDigest, this.protectedJson(decision));
|
|
252
280
|
});
|
|
253
281
|
}
|
|
254
282
|
retainProgress(budget, stage, value) {
|
package/dist/qaReview.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { withGraphDerivedAccess } from "./graph/derived.js";
|
|
2
2
|
import { dispatchWithGraphAccess } from "./graph/session.js";
|
|
3
|
-
import { finalQaRoleInstructions, resolveEffectiveQaConfiguration } from "./qaEffectiveConfig.js";
|
|
3
|
+
import { finalQaRoleInstructions, QA_COMMAND_DISCIPLINE, resolveEffectiveQaConfiguration } from "./qaEffectiveConfig.js";
|
|
4
4
|
import { COVERAGE_INSTRUCTIONS, bindCoverageHostFields, parseContractCoverage, validateContractCoverage } from "./qaContractCoverage.js";
|
|
5
5
|
import { contractDigest, renderVerificationContract } from "./qaVerificationContract.js";
|
|
6
6
|
import { finalReviewContractContext } from "./qaContractDelivery.js";
|
|
@@ -26,7 +26,10 @@ import { canonicalSessionPath } from "./sessionIdentity.js";
|
|
|
26
26
|
import { loadProjectAutonomyConfig, resolveAutonomyPolicy } from "./recoveryPolicy.js";
|
|
27
27
|
import { appendQaRecoveryAttempt, appendQaRecoveryResource, appendQaRecoveryReviewedState, compareQaRecoveryReviewedState, createQaRecoveryPacket, ensureQaRecoveryExcluded, loadQaRecoveryPacket, materializeQaRecoveryContext, qaRecoveryInventory, qaReportDigest, renderQaRecoveryAcknowledgementInstruction, updateQaRecoveryPosition, updateQaRecoveryReviewIdentity, validateManualQaReport, validateQaRecoveryAcknowledgement, } from "./qaRecovery.js";
|
|
28
28
|
import { canonicalJson, createQaFindingRefs, qaDigest } from "./qaProtocolV2.js";
|
|
29
|
-
import {
|
|
29
|
+
import { createHostQaComparison, findHostQaCheck } from "./hostQaComparison.js";
|
|
30
|
+
import { createHostGraphApplicability, findGraphQaChecks } from "./hostGraphApplicability.js";
|
|
31
|
+
import { applyHostQaAugmentations } from "./hostQaAugmentation.js";
|
|
32
|
+
import { qaUnresolvedToolDecision, recordQaUnresolvedTools } from "./qaToolRecovery.js";
|
|
30
33
|
function ensureProtocolPausePacket(opts, head, stage, detail, frozenState) {
|
|
31
34
|
const db = new WorkflowDb(opts.recovery.projectDir);
|
|
32
35
|
try {
|
|
@@ -946,6 +949,13 @@ async function oneReview(opts, identity) {
|
|
|
946
949
|
responseProtocolError = `invalid QA response contract after blocker recovery: ${responseContract.errors.join("; ")}`;
|
|
947
950
|
}
|
|
948
951
|
}
|
|
952
|
+
const unresolvedDecision = qaUnresolvedToolDecision(opts.recovery.projectDir, opts.recovery.runId, opts.ticket.id, qaEvents);
|
|
953
|
+
if (unresolvedDecision.action !== "none") {
|
|
954
|
+
recordQaUnresolvedTools(opts.recovery.projectDir, opts.recovery.runId, opts.ticket.id, qaEvents);
|
|
955
|
+
if (unresolvedDecision.action === "retry")
|
|
956
|
+
return retryFreshQaReview(opts, "unresolved-provider-tool", unresolvedDecision.detail, (recoverySnapshot ?? snapshot).frozenState);
|
|
957
|
+
return pauseQaReview(opts, "unresolved-provider-tool", unresolvedDecision.detail, (recoverySnapshot ?? snapshot).frozenState);
|
|
958
|
+
}
|
|
949
959
|
opts.state.reviews += 1;
|
|
950
960
|
opts.state.sessionId = qa.sessionId();
|
|
951
961
|
opts.state.sessionRef = qa.sessionRef?.();
|
|
@@ -1155,6 +1165,7 @@ export function buildQaReviewHandoff(ticket, builderSummary, diffDigest, validat
|
|
|
1155
1165
|
`Project validation checklist: ${validationChecklist.join("; ")}`,
|
|
1156
1166
|
`Prior authoritative QA/report and Builder-remediation history: ${history.length ? JSON.stringify(boundedQaHistory(history)) : "(none; perform a complete first review)"}`,
|
|
1157
1167
|
`Failure report JSON Schema: ${JSON.stringify(qaFailureReportV1Schema)}`,
|
|
1168
|
+
QA_COMMAND_DISCIPLINE,
|
|
1158
1169
|
`A failing response must use exactly:\n${QA_FAILURE_REPORT_START}\n{...valid QaFailureReportV1 JSON...}\n${QA_FAILURE_REPORT_END}\nSTEP_STATUS: qa_fail | issues="short plain-text synopsis"`,
|
|
1159
1170
|
"Be very thorough. Don't leave anything out. Make sure all required fields are added. Triple check your work to ensure nothing is left out and all required fields are added and populated correctly.",
|
|
1160
1171
|
"Do not change source, configuration, documentation, tickets, or control files. Only ignored cache, coverage, and build output is allowed.",
|
|
@@ -2010,9 +2021,11 @@ function finishV2Pass(opts, adapter, detail) {
|
|
|
2010
2021
|
const hostComparison = findHostQaCheck(contract)
|
|
2011
2022
|
? createHostQaComparison({ db, projectDir: binding.projectDir, worktree: opts.builderWorktree, runId: binding.runId, ticket: opts.ticket, contract, reviewAttemptId: binding.reviewAttemptId, reviewBasisDigest: binding.reviewBasisDigest })
|
|
2012
2023
|
: undefined;
|
|
2013
|
-
const
|
|
2014
|
-
?
|
|
2015
|
-
:
|
|
2024
|
+
const hostGraph = findGraphQaChecks(contract).length
|
|
2025
|
+
? createHostGraphApplicability({ projectDir: binding.projectDir, runId: binding.runId, ticketId: binding.ticketId, contract, reviewAttemptId: binding.reviewAttemptId, reviewBasisDigest: binding.reviewBasisDigest, sourceDigest: binding.sourceStateDigest, store: db.qaPreparationStore() })
|
|
2026
|
+
: undefined;
|
|
2027
|
+
const commandObservations = retainedQaCommandObservations(opts.recovery.projectDir, opts.recovery.runId, opts.ticket.id, false);
|
|
2028
|
+
const coverage = applyHostQaAugmentations({ coverage: providerCoverage, contract, projectDir: binding.projectDir, hostComparison, hostGraph, commandObservations });
|
|
2016
2029
|
const errors = validateContractCoverage(contract, coverage, coverageBinding);
|
|
2017
2030
|
if (errors.length)
|
|
2018
2031
|
throw new Error(`Final QA coverage incomplete: ${errors.join("; ")}`);
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { BuilderEvent } from "./adapters/types.js";
|
|
2
|
+
export type QaUnresolvedToolDecision = {
|
|
3
|
+
action: "none";
|
|
4
|
+
} | {
|
|
5
|
+
action: "retry";
|
|
6
|
+
detail: string;
|
|
7
|
+
} | {
|
|
8
|
+
action: "pause";
|
|
9
|
+
detail: string;
|
|
10
|
+
};
|
|
11
|
+
export declare function recordQaUnresolvedTools(projectDir: string, runId: string, ticketId: string, events: BuilderEvent[]): void;
|
|
12
|
+
export declare function qaUnresolvedToolDecision(projectDir: string, runId: string, ticketId: string, events: BuilderEvent[]): QaUnresolvedToolDecision;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { WorkflowDb } from "./workflowDb.js";
|
|
2
|
+
function unresolvedTools(events) {
|
|
3
|
+
const latest = new Map();
|
|
4
|
+
for (const event of events)
|
|
5
|
+
if (event.kind === "tool" && event.callId && event.lifecycle === "completed")
|
|
6
|
+
latest.set(event.callId, event);
|
|
7
|
+
return [...latest.values()].filter(event => event.completionKnown === false).map(event => ({ callId: event.callId, digest: event.outputDigest, name: event.name }));
|
|
8
|
+
}
|
|
9
|
+
export function recordQaUnresolvedTools(projectDir, runId, ticketId, events) {
|
|
10
|
+
const unresolved = unresolvedTools(events);
|
|
11
|
+
if (!unresolved.length)
|
|
12
|
+
return;
|
|
13
|
+
const db = new WorkflowDb(projectDir);
|
|
14
|
+
try {
|
|
15
|
+
const prior = db.continuityEvents(runId).filter(event => event.kind === "qa-unresolved-tool-result" && event.payload.ticketId === ticketId);
|
|
16
|
+
const known = new Set(prior.map(event => JSON.stringify(event.payload)));
|
|
17
|
+
const head = db.qaTicketHead(runId, ticketId);
|
|
18
|
+
for (const item of unresolved) {
|
|
19
|
+
const payload = { ticketId, callId: item.callId, toolName: item.name, outputDigest: item.digest ?? null };
|
|
20
|
+
if (!known.has(JSON.stringify(payload)))
|
|
21
|
+
db.appendContinuityEvent({ runId, role: "qa", kind: "qa-unresolved-tool-result", payload, authoritativeStateRevision: head.revision });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
finally {
|
|
25
|
+
db.close();
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export function qaUnresolvedToolDecision(projectDir, runId, ticketId, events) {
|
|
29
|
+
const unresolved = unresolvedTools(events);
|
|
30
|
+
if (!unresolved.length)
|
|
31
|
+
return { action: "none" };
|
|
32
|
+
const db = new WorkflowDb(projectDir);
|
|
33
|
+
try {
|
|
34
|
+
const records = db.continuityEvents(runId).filter(event => event.kind === "qa-unresolved-tool-result" && event.payload.ticketId === ticketId);
|
|
35
|
+
const priorKeys = new Set(records.map(event => { const payload = event.payload; return payload.outputDigest ?? payload.toolName; }));
|
|
36
|
+
const repeated = unresolved.find(item => priorKeys.has(item.digest ?? item.name));
|
|
37
|
+
if (repeated)
|
|
38
|
+
return { action: "pause", detail: `QA tool result remains unresolved after one controlled recovery (${repeated.name}, ${repeated.digest ?? repeated.name}); no provider action was replayed` };
|
|
39
|
+
return { action: "retry", detail: `QA observed an unresolved ${unresolved[0].name} result; starting one fresh review without replaying the uncertain tool action` };
|
|
40
|
+
}
|
|
41
|
+
finally {
|
|
42
|
+
db.close();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -10,6 +10,8 @@ export declare function allocateStableCheckIds(candidate: QaContractCandidateV1)
|
|
|
10
10
|
export declare function inventoryDigest(requirements: RequirementRef[]): string;
|
|
11
11
|
/** Derive a missing baseline binding only from an explicit, unique check prerequisite reference. */
|
|
12
12
|
export declare function bindUniqueBaselineCheckIds(candidate: QaContractCandidateV1): QaContractCandidateV1;
|
|
13
|
+
/** Ensure the host-owned scope invariant survives providers that omit or rename it. */
|
|
14
|
+
export declare function ensureHostScopeCheck(candidate: QaContractCandidateV1, baselineId: string): QaContractCandidateV1;
|
|
13
15
|
/**
|
|
14
16
|
* The host owns the before/after path comparison. Providers may still draft a
|
|
15
17
|
* check that asks them to enumerate every changed file, which turns host
|
|
@@ -113,8 +113,12 @@ export function inventoryDigest(requirements) { return contractDigest("qa-requir
|
|
|
113
113
|
/** Derive a missing baseline binding only from an explicit, unique check prerequisite reference. */
|
|
114
114
|
export function bindUniqueBaselineCheckIds(candidate) {
|
|
115
115
|
const draft = structuredClone(candidate);
|
|
116
|
+
const proposedIds = new Set(draft.checks.map(check => check.id));
|
|
116
117
|
for (const observation of draft.baseline) {
|
|
117
|
-
|
|
118
|
+
// A correction may copy a stable check ID from an earlier draft. Repair
|
|
119
|
+
// that stale binding from the observation's unique prerequisite instead
|
|
120
|
+
// of sending it through stable-ID allocation as if it were current.
|
|
121
|
+
if (observation.checkId && proposedIds.has(observation.checkId))
|
|
118
122
|
continue;
|
|
119
123
|
const matches = draft.checks.filter(check => check.prerequisiteRefs.includes(observation.id));
|
|
120
124
|
if (matches.length === 1)
|
|
@@ -122,6 +126,36 @@ export function bindUniqueBaselineCheckIds(candidate) {
|
|
|
122
126
|
}
|
|
123
127
|
return draft;
|
|
124
128
|
}
|
|
129
|
+
/** Ensure the host-owned scope invariant survives providers that omit or rename it. */
|
|
130
|
+
export function ensureHostScopeCheck(candidate, baselineId) {
|
|
131
|
+
const copy = structuredClone(candidate);
|
|
132
|
+
const hasHostScope = copy.checks.some(check => check.applicability.decisionOwner === "host"
|
|
133
|
+
&& /scope comparison|product paths|preimplementation baseline/i.test(`${check.expectedBehavior} ${check.expectedEvidence.join(" ")} ${check.applicability.requiredEvidence.join(" ")}`));
|
|
134
|
+
if (hasHostScope)
|
|
135
|
+
return copy;
|
|
136
|
+
// Every contract check must trace to at least one authoritative requirement.
|
|
137
|
+
// The host scope invariant is cross-cutting rather than provider-authored, so
|
|
138
|
+
// bind it to the first approved requirement when a provider omitted the
|
|
139
|
+
// invariant entirely. This keeps the invariant structurally valid without
|
|
140
|
+
// inventing a new requirement outside the authoritative inventory.
|
|
141
|
+
const scopeRequirement = copy.requirements[0]?.id;
|
|
142
|
+
if (!scopeRequirement)
|
|
143
|
+
throw new Error("Cannot add host scope check without an authoritative requirement");
|
|
144
|
+
copy.checks.push({
|
|
145
|
+
id: "host-scope",
|
|
146
|
+
requirementRefs: [scopeRequirement],
|
|
147
|
+
origin: "invariant",
|
|
148
|
+
expectedBehavior: "Rafi compares the final product paths with the host-captured preimplementation baseline and rejects unauthorized product changes.",
|
|
149
|
+
obligation: "mandatory",
|
|
150
|
+
timing: "postimplementation",
|
|
151
|
+
applicability: { kind: "unconditional", decisionOwner: "host", requiredEvidence: [baselineId] },
|
|
152
|
+
verification: [{ kind: "procedure", steps: ["Rafi performs the host-owned before/after product-path comparison, excluding approved bookkeeping and bounded evidence paths."], cwd: ".", fixtures: [], runtime: "Rafi host", timeoutMs: 30_000, expectedOutcome: "Only approved product changes are present; unauthorized, destructive, protected, or requirement-relevant changes are rejected." }],
|
|
153
|
+
expectedEvidence: ["Rafi host-owned postimplementation scope comparison bound to the retained preimplementation baseline."],
|
|
154
|
+
prerequisiteRefs: [baselineId],
|
|
155
|
+
dependsOnChecks: [],
|
|
156
|
+
});
|
|
157
|
+
return copy;
|
|
158
|
+
}
|
|
125
159
|
/**
|
|
126
160
|
* The host owns the before/after path comparison. Providers may still draft a
|
|
127
161
|
* check that asks them to enumerate every changed file, which turns host
|
|
@@ -135,7 +169,8 @@ export function rewriteProviderHostScopeChecks(candidate, baselineId) {
|
|
|
135
169
|
return /\b(?:complete|full|all|every)\s+(?:changed|modified|touched)[ -](?:path|paths|file|files)\b/.test(text)
|
|
136
170
|
|| /\b(?:changed|modified|touched)[ -](?:path|paths|file|files)\s+(?:inventory|list|manifest)\b/.test(text)
|
|
137
171
|
|| /\bunchanged[- ]files?\s+(?:comparison|check|verification)\b/.test(text)
|
|
138
|
-
|| /\bpath\s+manifest\b.{0,80}\b(?:compare|comparison|unchanged|scope)\b/.test(text)
|
|
172
|
+
|| /\bpath\s+manifest\b.{0,80}\b(?:compare|comparison|unchanged|scope)\b/.test(text)
|
|
173
|
+
|| (check.applicability.decisionOwner === "host" && /scope.{0,80}comparison|comparison.{0,80}scope|product paths?.{0,100}baseline/.test(text));
|
|
139
174
|
};
|
|
140
175
|
const matches = draft.checks.filter(hostScope);
|
|
141
176
|
if (!matches.length)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { WorkflowDb } from "./workflowDb.js";
|
|
2
|
-
import type
|
|
2
|
+
import { type ResolvedAutonomyPolicy } from "rafi-spec";
|
|
3
3
|
/** Worker IPC is created by the parent; an environment flag alone cannot bypass supervision. */
|
|
4
4
|
export declare function supervisedRunId(): string | undefined;
|
|
5
5
|
/** Both ordinary and branch starts enter through this process boundary. */
|
package/dist/supervisedStart.js
CHANGED
|
@@ -6,6 +6,7 @@ import { join } from "node:path";
|
|
|
6
6
|
import { hostname } from "node:os";
|
|
7
7
|
import { DurableSupervisor } from "./supervisor.js";
|
|
8
8
|
import { WorkflowDb } from "./workflowDb.js";
|
|
9
|
+
import { stopCause } from "rafi-spec";
|
|
9
10
|
import { processStartIdentity } from "./processIdentity.js";
|
|
10
11
|
import { cancelOwnedRuntimeProbes } from "./runtimeReadiness.js";
|
|
11
12
|
/** Worker IPC is created by the parent; an environment flag alone cannot bypass supervision. */
|
|
@@ -51,8 +52,9 @@ export async function superviseStart(projectDir, runId, policy, selection) {
|
|
|
51
52
|
const owner = durable.supervisorState(runId);
|
|
52
53
|
if (owner?.workerGeneration !== Number(process.env.RAFI_BUILD_WORKER_GENERATION) || owner.workerPid !== process.pid)
|
|
53
54
|
return;
|
|
54
|
-
|
|
55
|
-
durable.
|
|
55
|
+
const cause = stopCause({ cause: "worker-disconnect", source: "worker", actor: "worker", runId, supervisorGeneration: owner.generation, workerGeneration: owner.workerGeneration, pid: process.pid, processStart: processStartIdentity(), checkpoint: "orphaned-worker", at: new Date().toISOString(), requested: false, detail: "Worker IPC disconnected before the supervisor acknowledged shutdown" });
|
|
56
|
+
durable.transition(runId, { status: "paused", checkpoint: "orphaned-worker", event: "supervisor_disconnected", payload: { workerPid: process.pid, cause } });
|
|
57
|
+
durable.putSupervisorState(runId, { ...owner, status: "stopping", stopCause: cause });
|
|
56
58
|
});
|
|
57
59
|
}
|
|
58
60
|
catch (error) {
|
|
@@ -156,9 +158,10 @@ export async function superviseStart(projectDir, runId, policy, selection) {
|
|
|
156
158
|
db.ensureRun(runId, "build");
|
|
157
159
|
db.freezeAutonomyPolicy(runId, policy);
|
|
158
160
|
let exitCode = 2;
|
|
159
|
-
let
|
|
161
|
+
let stopRequestCause;
|
|
160
162
|
const supervisor = new DurableSupervisor({ projectDir, runId, policy, admission: localBuildAuthority(projectDir),
|
|
161
163
|
checkpoint: () => db.getRun(runId)?.checkpoint ?? "preparing",
|
|
164
|
+
onStopRequested: cause => { stopRequestCause = cause; },
|
|
162
165
|
spawnWorker: generation => {
|
|
163
166
|
let authority = localBuildAuthority(projectDir);
|
|
164
167
|
if (db.buildAdmission()?.token !== authority.token) {
|
|
@@ -212,7 +215,7 @@ export async function superviseStart(projectDir, runId, policy, selection) {
|
|
|
212
215
|
};
|
|
213
216
|
const heartbeatTimeout = policy.runtimeDeadlines?.heartbeat_ms ?? 120_000;
|
|
214
217
|
const watchdog = setInterval(() => { if (Date.now() - lastHeartbeat > heartbeatTimeout) {
|
|
215
|
-
|
|
218
|
+
stopRequestCause = stopCause({ cause: "heartbeat-timeout", source: "heartbeat", actor: "supervisor", runId, supervisorGeneration: generation, workerGeneration: generation, checkpoint: db.getRun(runId)?.checkpoint ?? "unknown", at: new Date().toISOString(), requested: true, detail: `worker heartbeat exceeded ${heartbeatTimeout}ms` });
|
|
216
219
|
stop();
|
|
217
220
|
} }, Math.min(5_000, Math.max(25, heartbeatTimeout / 4)));
|
|
218
221
|
watchdog.unref();
|
|
@@ -256,8 +259,15 @@ export async function superviseStart(projectDir, runId, policy, selection) {
|
|
|
256
259
|
exitCode = code ?? 2;
|
|
257
260
|
try {
|
|
258
261
|
const exitState = db.getRun(runId);
|
|
262
|
+
const exitCause = stopRequestCause ?? stopCause({ cause: "worker-exit", source: "worker", actor: "worker", runId, supervisorGeneration: generation, workerGeneration: generation, pid: child.pid, processStart: child.pid ? processStartIdentity(child.pid) : undefined, checkpoint: exitState?.checkpoint ?? "unknown", at: new Date().toISOString(), requested: false, detail: signal ? `worker exited from ${signal}` : `worker exited with code ${code ?? "unknown"}` });
|
|
259
263
|
if (exitState)
|
|
260
|
-
db.transitionSupervisor(runId, generation, { checkpoint: exitState.checkpoint, event: "worker_exit", payload: {
|
|
264
|
+
db.transitionSupervisor(runId, generation, { checkpoint: exitState.checkpoint, event: "worker_exit", payload: {
|
|
265
|
+
code, signal, stopRequested: stopping, cause: exitCause,
|
|
266
|
+
// Preserve the historical diagnostic field while consumers migrate
|
|
267
|
+
// to the closed StopCause contract.
|
|
268
|
+
source: exitCause.cause === "parent-sigterm" ? "parent-SIGTERM" : exitCause.cause === "user-sigint" ? "user-SIGINT" : exitCause.cause === "heartbeat-timeout" ? "heartbeat-timeout" : exitCause.cause,
|
|
269
|
+
at: new Date().toISOString(), generation,
|
|
270
|
+
} });
|
|
261
271
|
// Durable records are authoritative even when the worker never sent IPC.
|
|
262
272
|
const deadline = Date.now() + 5000;
|
|
263
273
|
const owned = db.readinessProcesses(runId).filter(row => {
|
|
@@ -286,14 +296,36 @@ export async function superviseStart(projectDir, runId, policy, selection) {
|
|
|
286
296
|
const workerQuiescent = await ownedGroupIsQuiescent(child.pid);
|
|
287
297
|
const preparationReconciled = canRestartPreparation(db, runId);
|
|
288
298
|
if (!stopping && readinessQuiescent && workerQuiescent && preparationReconciled) {
|
|
289
|
-
|
|
299
|
+
const preparationCause = stopCause({
|
|
300
|
+
cause: "worker-exit", source: "worker", actor: "worker", runId,
|
|
301
|
+
supervisorGeneration: generation, workerGeneration: generation,
|
|
302
|
+
pid: child.pid, processStart: child.pid ? processStartIdentity(child.pid) : undefined,
|
|
303
|
+
checkpoint: "preparation-reconciled", at: new Date().toISOString(), requested: false,
|
|
304
|
+
detail: "Worker exited before acquiring implementation authority; owned process group is quiescent",
|
|
305
|
+
});
|
|
306
|
+
db.transitionSupervisor(runId, generation, { status: "paused", checkpoint: "preparation-reconciled", event: "worker_preparation_reconciled", payload: { generation, signal, noImplementationAuthority: true, cause: preparationCause } });
|
|
290
307
|
resolve({ kind: "crashed", detail: "Preparation worker exited before acquiring implementation authority; owned process group is quiescent" });
|
|
291
308
|
return;
|
|
292
309
|
}
|
|
293
310
|
// Process death does not establish that remote side effects stopped.
|
|
294
311
|
// Never replay an unacknowledged provider action automatically.
|
|
295
312
|
const uncertain = [...db.unresolvedRoleDispatches(runId, "builder"), ...db.unresolvedRoleDispatches(runId, "qa")];
|
|
296
|
-
|
|
313
|
+
const reconciliationCause = stopCause({
|
|
314
|
+
...(stopRequestCause ?? { runId, supervisorGeneration: generation, workerGeneration: generation }),
|
|
315
|
+
cause: "worker-reconciliation-required",
|
|
316
|
+
source: "reconciliation",
|
|
317
|
+
actor: "supervisor",
|
|
318
|
+
runId,
|
|
319
|
+
supervisorGeneration: generation,
|
|
320
|
+
workerGeneration: generation,
|
|
321
|
+
pid: child.pid,
|
|
322
|
+
processStart: child.pid ? processStartIdentity(child.pid) : undefined,
|
|
323
|
+
checkpoint: uncertain.length ? "uncertain-worker-dispatch" : "worker-interrupted",
|
|
324
|
+
at: new Date().toISOString(),
|
|
325
|
+
requested: false,
|
|
326
|
+
detail: "Worker stopped before all owned work had a durable terminal outcome",
|
|
327
|
+
});
|
|
328
|
+
db.transitionSupervisor(runId, generation, { status: "paused", checkpoint: uncertain.length ? "uncertain-worker-dispatch" : "worker-interrupted", event: "worker_reconciliation_required", payload: { generation, signal, cause: reconciliationCause, uncertainDispatches: uncertain.map(item => item.idempotencyKey), readinessWasActive, readinessQuiescent, workerQuiescent, preparationReconciled, readinessCleanup, readinessOwnershipVerified: [...readinessChildren.values()].every(value => value !== "unavailable") } });
|
|
297
329
|
resolve({ kind: "waiting_for_human" });
|
|
298
330
|
}
|
|
299
331
|
else {
|
|
@@ -318,8 +350,8 @@ export async function superviseStart(projectDir, runId, policy, selection) {
|
|
|
318
350
|
return { pid: child.pid, result, stop };
|
|
319
351
|
},
|
|
320
352
|
});
|
|
321
|
-
const stopInt = () => {
|
|
322
|
-
const stopTerm = () => {
|
|
353
|
+
const stopInt = () => { supervisor.requestStop("user-sigint"); };
|
|
354
|
+
const stopTerm = () => { supervisor.requestStop("parent-sigterm"); };
|
|
323
355
|
process.once("SIGINT", stopInt);
|
|
324
356
|
process.once("SIGTERM", stopTerm);
|
|
325
357
|
let supervisorCompleted = false;
|
|
@@ -331,12 +363,13 @@ export async function superviseStart(projectDir, runId, policy, selection) {
|
|
|
331
363
|
const run = db.getRun(runId);
|
|
332
364
|
if (!state || state.pid !== process.pid || state.status !== "running" || !run || ["completed", "cancelled", "superseded"].includes(run.status))
|
|
333
365
|
return;
|
|
334
|
-
|
|
366
|
+
const cause = stopCause({ cause: "supervisor-process-exit", source: "supervisor", actor: "supervisor", runId, supervisorGeneration: state.generation, workerGeneration: state.workerGeneration, pid: process.pid, processStart: state.processStart, checkpoint: run.checkpoint, at: new Date().toISOString(), requested: false, detail: `supervisor exited with code ${code}` });
|
|
367
|
+
db.putSupervisorState(runId, { ...state, status: "failed", pid: undefined, workerPid: undefined, heartbeatAt: new Date().toISOString(), stopCause: cause });
|
|
335
368
|
db.transitionSupervisor(runId, state.workerGeneration, {
|
|
336
369
|
status: "paused",
|
|
337
370
|
checkpoint: "supervisor-process-exit",
|
|
338
371
|
event: "supervisor_process_exit",
|
|
339
|
-
payload: { exitCode: code, lastCheckpoint: run.checkpoint, generation: state.generation },
|
|
372
|
+
payload: { exitCode: code, lastCheckpoint: run.checkpoint, generation: state.generation, cause },
|
|
340
373
|
});
|
|
341
374
|
}
|
|
342
375
|
catch { /* Exit handlers must not mask the original process termination. */ }
|
package/dist/supervisor.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { BuildAdmission } from "./buildAdmission.js";
|
|
2
|
-
import type
|
|
2
|
+
import { type ResolvedAutonomyPolicy, type StopCause, type StopCauseName, type SupervisorState } from "rafi-spec";
|
|
3
3
|
export type WorkerOutcome = {
|
|
4
4
|
kind: "completed";
|
|
5
5
|
} | {
|
|
@@ -18,6 +18,19 @@ export interface SupervisorWorkerHandle {
|
|
|
18
18
|
result: Promise<WorkerOutcome>;
|
|
19
19
|
stop: () => Promise<void> | void;
|
|
20
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* Retry only the short synchronous persistence operation. The caller owns
|
|
23
|
+
* the retry budget and remains responsible for deciding whether ownership is
|
|
24
|
+
* safe after the budget is exhausted.
|
|
25
|
+
*/
|
|
26
|
+
export declare function persistWithBoundedRetry(persist: () => void, sleep: (milliseconds: number) => Promise<void>, attempts?: number): Promise<{
|
|
27
|
+
ok: true;
|
|
28
|
+
attempts: number;
|
|
29
|
+
} | {
|
|
30
|
+
ok: false;
|
|
31
|
+
attempts: number;
|
|
32
|
+
error: unknown;
|
|
33
|
+
}>;
|
|
21
34
|
export interface DurableSupervisorOptions {
|
|
22
35
|
projectDir: string;
|
|
23
36
|
runId: string;
|
|
@@ -27,16 +40,19 @@ export interface DurableSupervisorOptions {
|
|
|
27
40
|
checkpoint: () => string;
|
|
28
41
|
sleep?: (milliseconds: number) => Promise<void>;
|
|
29
42
|
now?: () => Date;
|
|
43
|
+
onStopRequested?: (cause: StopCause) => void;
|
|
44
|
+
heartbeatIntervalMs?: number;
|
|
30
45
|
}
|
|
31
46
|
/** Durable worker restart loop. The supervisor never owns the project's mutation lease. */
|
|
32
47
|
export declare class DurableSupervisor {
|
|
33
48
|
private readonly options;
|
|
34
49
|
private stopRequested;
|
|
50
|
+
private requestedStopCause?;
|
|
35
51
|
private active?;
|
|
36
52
|
private readonly sleep;
|
|
37
53
|
private readonly now;
|
|
38
54
|
constructor(options: DurableSupervisorOptions);
|
|
39
|
-
requestStop(): void;
|
|
55
|
+
requestStop(cause?: StopCauseName): void;
|
|
40
56
|
run(): Promise<WorkerOutcome>;
|
|
41
57
|
}
|
|
42
58
|
export declare function initialSupervisorState(): SupervisorState;
|