ai-foreman 2.0.0 → 2.0.1
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 +4 -3
- package/dist/adapters/codex.d.ts +2 -0
- package/dist/adapters/codex.js +7 -2
- package/dist/branch/git.js +3 -8
- package/dist/buildApproval.js +3 -9
- package/dist/buildRuns.d.ts +1 -0
- package/dist/buildRuns.js +6 -1
- package/dist/cli/doctor.js +3 -1
- package/dist/cli/start.js +63 -13
- package/dist/cli/tickets.d.ts +0 -1
- package/dist/cli/tickets.js +11 -22
- package/dist/continuity.d.ts +0 -2
- package/dist/continuity.js +30 -34
- package/dist/dispatchNotSent.d.ts +4 -0
- package/dist/dispatchNotSent.js +10 -0
- package/dist/foreman.d.ts +5 -12
- package/dist/foreman.js +19 -85
- package/dist/gitExclude.d.ts +16 -0
- package/dist/gitExclude.js +46 -0
- package/dist/graph/turn.d.ts +1 -1
- package/dist/graph/turn.js +1 -1
- package/dist/handoffs.d.ts +3 -3
- package/dist/handoffs.js +5 -32
- package/dist/humanDecision.d.ts +7 -0
- package/dist/humanDecision.js +11 -0
- package/dist/observability.js +3 -8
- package/dist/qaBuildGate.js +13 -1
- package/dist/qaPreparation.js +21 -7
- package/dist/qaPreparationStore.d.ts +11 -0
- package/dist/qaPreparationStore.js +44 -4
- package/dist/qaRecovery.d.ts +0 -3
- package/dist/qaRecovery.js +1 -24
- package/dist/qaReview.js +32 -22
- package/dist/qaSnapshot.js +19 -14
- package/dist/qaStructuredResponse.d.ts +8 -0
- package/dist/qaStructuredResponse.js +24 -0
- package/dist/qaTimeline.js +3 -5
- package/dist/recoveryOwners.d.ts +32 -0
- package/dist/recoveryOwners.js +87 -0
- package/dist/runtimePreflight.d.ts +11 -0
- package/dist/runtimePreflight.js +77 -0
- package/dist/sources/sourceRegistry.d.ts +0 -3
- package/dist/sources/sourceRegistry.js +0 -42
- package/dist/structuredOperation.js +26 -4
- package/dist/ticketPlanning.d.ts +0 -3
- package/dist/ticketPlanning.js +0 -23
- package/dist/ticketPopulationPaging.js +23 -1
- package/dist/workflowDb.d.ts +16 -1
- package/dist/workflowDb.js +89 -22
- package/package.json +11 -3
package/dist/adapters/claude.js
CHANGED
|
@@ -63,14 +63,15 @@ export function buildClaudeQueryOptions(opts, scratchDirectory = join(dirname(op
|
|
|
63
63
|
failIfUnavailable: true,
|
|
64
64
|
autoAllowBashIfSandboxed: true,
|
|
65
65
|
allowUnsandboxedCommands: false,
|
|
66
|
-
|
|
66
|
+
// QA stays offline unless the operator granted --qa-network for this build.
|
|
67
|
+
network: opts.networkAccess === true ? { allowedDomains: ["*"] } : { allowedDomains: [], deniedDomains: ["*"] },
|
|
67
68
|
filesystem: { allowWrite: [scratchDirectory], denyWrite: [opts.cwd, ...(opts.configRoot ? [opts.configRoot] : [])] },
|
|
68
69
|
} } : {}),
|
|
69
70
|
};
|
|
70
71
|
const exactSkills = opts.preloadedSkillContent?.length
|
|
71
72
|
? ["# Preloaded Skills", "Use the following skills for this run.", ...opts.preloadedSkillContent.map((skill) => skill.content)].join("\n\n")
|
|
72
73
|
: undefined;
|
|
73
|
-
const systemAppend = [handoffSystemInstructions(opts.sessionRole, opts.systemPromptAppend), opts.sessionRole === "builder" ? `Builder runtime contract: shell network ${opts.networkAccess === true ? "explicitly approved for this build" : "disabled"}; sandbox fallback disabled. If acquisition requires unavailable access, request input first or use a provenance-verified local bundle. A ticket answer does not grant runtime permissions.` : undefined, exactSkills].filter((part) => Boolean(part)).join("\n\n");
|
|
74
|
+
const systemAppend = [handoffSystemInstructions(opts.sessionRole, opts.systemPromptAppend), opts.sessionRole === "builder" ? `Builder runtime contract: shell network ${opts.networkAccess === true ? "explicitly approved for this build" : "disabled"}; sandbox fallback disabled. If acquisition requires unavailable access, request input first or use a provenance-verified local bundle. A ticket answer does not grant runtime permissions.` : undefined, opts.sessionRole === "qa" && opts.networkAccess === true ? "QA runtime contract: shell network explicitly approved for this review (--qa-network); you may run live checks against operator-provided local or remote services. Workspace stays read-only." : undefined, exactSkills].filter((part) => Boolean(part)).join("\n\n");
|
|
74
75
|
if (systemAppend) {
|
|
75
76
|
base.systemPrompt = { type: "preset", preset: "claude_code", append: systemAppend };
|
|
76
77
|
}
|
|
@@ -1341,7 +1342,7 @@ export class ClaudeAdapter {
|
|
|
1341
1342
|
pending.idleTimer.unref();
|
|
1342
1343
|
}
|
|
1343
1344
|
providerIdleTimeoutMs() {
|
|
1344
|
-
const configured = this.opts.providerIdleTimeoutMs;
|
|
1345
|
+
const configured = this.opts.providerIdleTimeoutMs ?? Number(process.env.RAFI_PROVIDER_IDLE_TIMEOUT_MS);
|
|
1345
1346
|
return Number.isFinite(configured) && configured > 0 ? configured : DEFAULT_PROVIDER_IDLE_TIMEOUT_MS;
|
|
1346
1347
|
}
|
|
1347
1348
|
streamFailureResult(message, sessionUnavailable) {
|
package/dist/adapters/codex.d.ts
CHANGED
|
@@ -132,6 +132,8 @@ export declare class CodexAdapter implements BuilderAdapter {
|
|
|
132
132
|
private traceLifecycle;
|
|
133
133
|
private touchWaiters;
|
|
134
134
|
private providerIdleTimeoutMs;
|
|
135
|
+
/** Read-only QA may reach the network only with the operator's --qa-network grant. */
|
|
136
|
+
private qaNetworkAccess;
|
|
135
137
|
private effectiveNetworkAccess;
|
|
136
138
|
private effectiveApprovalPolicy;
|
|
137
139
|
/** Fail closed; approval is for this operation only, never a session cache or policy amendment. */
|
package/dist/adapters/codex.js
CHANGED
|
@@ -151,6 +151,7 @@ export class CodexAdapter {
|
|
|
151
151
|
buildInstruction(instruction) {
|
|
152
152
|
return [this.opts.systemPromptAppend,
|
|
153
153
|
this.opts.sessionRole === "builder" ? `Builder runtime contract: shell network ${this.effectiveNetworkAccess() ? "explicitly approved for this build" : "disabled"}; sandbox escalation ${this.effectiveApprovalPolicy() === "on-request" ? "requires separate approval for each exact operation" : "disabled"}. Web tools do not establish shell network access. If acquisition needs unavailable capabilities, request input before running acquisition, or use a local source/dependency bundle with verified provenance. A ticket answer does not change runtime permissions.` : undefined,
|
|
154
|
+
this.qaNetworkAccess() ? "QA runtime contract: shell network explicitly approved for this review (--qa-network); you may run live checks against operator-provided local or remote services. Workspace stays read-only." : undefined,
|
|
154
155
|
this.buildSkillsAppendix(), instruction]
|
|
155
156
|
.filter((part) => Boolean(part)).join("\n\n");
|
|
156
157
|
}
|
|
@@ -293,7 +294,7 @@ export class CodexAdapter {
|
|
|
293
294
|
...(this.opts.fast ? { serviceTier: this.fastTier ?? "default" } : {}),
|
|
294
295
|
approvalPolicy: policy?.responseOnly || policy?.purpose && policy.purpose !== "implementation" ? "never" : this.effectiveApprovalPolicy(),
|
|
295
296
|
sandboxPolicy: readOnlyTurn
|
|
296
|
-
? { type: "readOnly", networkAccess:
|
|
297
|
+
? { type: "readOnly", networkAccess: this.qaNetworkAccess() }
|
|
297
298
|
: { type: "workspaceWrite", writableRoots: [this.opts.cwd], networkAccess: this.effectiveNetworkAccess() },
|
|
298
299
|
};
|
|
299
300
|
let started;
|
|
@@ -1090,9 +1091,13 @@ export class CodexAdapter {
|
|
|
1090
1091
|
waiter.touch?.();
|
|
1091
1092
|
}
|
|
1092
1093
|
providerIdleTimeoutMs() {
|
|
1093
|
-
const configured = this.opts.providerIdleTimeoutMs;
|
|
1094
|
+
const configured = this.opts.providerIdleTimeoutMs ?? Number(process.env.RAFI_PROVIDER_IDLE_TIMEOUT_MS);
|
|
1094
1095
|
return Number.isFinite(configured) && configured > 0 ? configured : DEFAULT_PROVIDER_IDLE_TIMEOUT_MS;
|
|
1095
1096
|
}
|
|
1097
|
+
/** Read-only QA may reach the network only with the operator's --qa-network grant. */
|
|
1098
|
+
qaNetworkAccess() {
|
|
1099
|
+
return this.opts.sessionRole === "qa" && this.opts.networkAccess === true;
|
|
1100
|
+
}
|
|
1096
1101
|
effectiveNetworkAccess() {
|
|
1097
1102
|
return this.opts.sandboxMode !== "read-only" && this.opts.sessionRole !== "qa" && this.opts.networkAccess === true;
|
|
1098
1103
|
}
|
package/dist/branch/git.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { ensureLocalGitExcludes } from "../gitExclude.js";
|
|
1
2
|
import { invalidateGraphWorkspace } from "../graph/lifecycle.js";
|
|
2
3
|
import { execFileSync } from "node:child_process";
|
|
3
|
-
import {
|
|
4
|
+
import { mkdirSync, rmSync } from "node:fs";
|
|
4
5
|
import { dirname, join } from "node:path";
|
|
5
6
|
export function runGit(cwd, args) {
|
|
6
7
|
const stdout = execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
|
@@ -41,13 +42,7 @@ export function ensureCleanBaseWorktree(cwd, opts = {}) {
|
|
|
41
42
|
}
|
|
42
43
|
}
|
|
43
44
|
export function ensureForemanExcluded(cwd) {
|
|
44
|
-
|
|
45
|
-
if (!existsSync(excludePath))
|
|
46
|
-
return;
|
|
47
|
-
const line = ".foreman/";
|
|
48
|
-
if (readFileSync(excludePath, "utf8").split(/\r?\n/).includes(line))
|
|
49
|
-
return;
|
|
50
|
-
appendFileSync(excludePath, `\n${line}\n`, "utf8");
|
|
45
|
+
ensureLocalGitExcludes(cwd, [".foreman/"], { leadingBlankLine: true });
|
|
51
46
|
}
|
|
52
47
|
export function createTicketWorktree(projectDir, runId, branch, base) {
|
|
53
48
|
const worktreePath = join(projectDir, ".foreman", "worktrees", runId, branch.replace(/\//g, "__"));
|
package/dist/buildApproval.js
CHANGED
|
@@ -97,15 +97,9 @@ export function requiresBuildApproval(projectDir, ticketIds, explicitYes, conseq
|
|
|
97
97
|
}
|
|
98
98
|
if (scope.approved && !changedConsequences)
|
|
99
99
|
return false;
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
try {
|
|
104
|
-
return db.completedPlanApprovals().length > 0;
|
|
105
|
-
}
|
|
106
|
-
finally {
|
|
107
|
-
db.close();
|
|
108
|
-
}
|
|
100
|
+
// --yes is the operator's standing approval for this invocation: it never
|
|
101
|
+
// stops to ask, even when the scope differs from the recorded plan approval.
|
|
102
|
+
return !explicitYes;
|
|
109
103
|
}
|
|
110
104
|
/** Bind decisions to current files so an old answer cannot approve a changed scope. */
|
|
111
105
|
export function buildScopeRevision(projectDir, consequences) {
|
package/dist/buildRuns.d.ts
CHANGED
|
@@ -12,6 +12,7 @@ export interface CreateBuildRunInput {
|
|
|
12
12
|
tickets: string[];
|
|
13
13
|
authorizedBatch?: BuildRunRecordV2["authorizedBatch"];
|
|
14
14
|
builderCapabilities?: BuildRunRecordV2["builderCapabilities"];
|
|
15
|
+
qaCapabilities?: BuildRunRecordV2["qaCapabilities"];
|
|
15
16
|
deliveryUnit?: string;
|
|
16
17
|
branchMode?: BuildRunRecordV2["branchMode"];
|
|
17
18
|
repositoryRoot: string;
|
package/dist/buildRuns.js
CHANGED
|
@@ -6,7 +6,7 @@ import { hostname } from "node:os";
|
|
|
6
6
|
import { join, resolve } from "node:path";
|
|
7
7
|
import { execFileSync } from "node:child_process";
|
|
8
8
|
import Database from "better-sqlite3";
|
|
9
|
-
import { WorkflowDb, heartbeatCurrentWorkflowLease, readCurrentWorkflowLease } from "./workflowDb.js";
|
|
9
|
+
import { WorkflowDb, heartbeatCurrentWorkflowLease, isNonBuildRunId, readCurrentWorkflowLease } from "./workflowDb.js";
|
|
10
10
|
import { WorkflowReader } from "./workflowReader.js";
|
|
11
11
|
import { canonicalSessionPath, captureWorkspaceIdentity, createProviderSessionRef, latestSessionBinding, upsertSessionBinding } from "./sessionIdentity.js";
|
|
12
12
|
import { resolveProviderSessionAvailability } from "./sessionAvailability.js";
|
|
@@ -53,6 +53,7 @@ export function createBuildRun(input) {
|
|
|
53
53
|
tickets: [...input.tickets],
|
|
54
54
|
authorizedBatch: input.authorizedBatch ?? (input.tickets.length && (input.branchMode ?? "current") === "current" ? { tickets: [...input.tickets], requestedSteps: input.tickets.length, scopeRevision: "explicit-create-selection", startedTickets: [] } : undefined),
|
|
55
55
|
builderCapabilities: input.builderCapabilities,
|
|
56
|
+
qaCapabilities: input.qaCapabilities,
|
|
56
57
|
deliveryUnit: input.deliveryUnit,
|
|
57
58
|
branchMode: input.branchMode ?? "current",
|
|
58
59
|
checkpoint: "created",
|
|
@@ -435,6 +436,10 @@ export function readBuildRuns(projectDir) {
|
|
|
435
436
|
catch { /* authoritative DB state can repair an absent or partial projection */ }
|
|
436
437
|
}
|
|
437
438
|
for (const stored of workflow.buildRuns()) {
|
|
439
|
+
// Journal runs for agent calls outside a build were once stored with the
|
|
440
|
+
// build kind; they are never builds.
|
|
441
|
+
if (isNonBuildRunId(stored.runId))
|
|
442
|
+
continue;
|
|
438
443
|
const value = stored.state;
|
|
439
444
|
if (value?.runId)
|
|
440
445
|
candidates.set(value.runId, value);
|
package/dist/cli/doctor.js
CHANGED
|
@@ -13,6 +13,7 @@ import { WorkflowReader } from "../workflowReader.js";
|
|
|
13
13
|
import { WorkflowDb } from "../workflowDb.js";
|
|
14
14
|
import { error, success, warning } from "../terminalStyle.js";
|
|
15
15
|
import { readInterviewRecords } from "../interviews.js";
|
|
16
|
+
import { checkRuntimeEnvironment } from "../runtimePreflight.js";
|
|
16
17
|
const PACKAGE_VERSION = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8"))?.version;
|
|
17
18
|
function commandVersion(command) {
|
|
18
19
|
const result = spawnSync(command, ["--version"], {
|
|
@@ -50,7 +51,8 @@ export function buildDoctorCommand() {
|
|
|
50
51
|
const warn = (label, detail) => {
|
|
51
52
|
console.log(`${warning("--")} ${label}${detail ? ` — ${detail}` : ""}`);
|
|
52
53
|
};
|
|
53
|
-
|
|
54
|
+
for (const check of checkRuntimeEnvironment())
|
|
55
|
+
report(check.ok, check.label, check.fix ? `${check.detail}; fix: ${check.fix}` : check.detail);
|
|
54
56
|
report(Boolean(PACKAGE_VERSION), "foreman package version", PACKAGE_VERSION);
|
|
55
57
|
report(existsSync(cwd), "project directory exists", cwd);
|
|
56
58
|
if (!existsSync(cwd))
|
package/dist/cli/start.js
CHANGED
|
@@ -11,7 +11,7 @@ import { durableReadOnlyProposal } from "../readOnlyProposal.js";
|
|
|
11
11
|
import { captureFrozenQaSource } from "../qaSnapshot.js";
|
|
12
12
|
import { finishStartAdmission, superviseStart, supervisedRunId } from "../supervisedStart.js";
|
|
13
13
|
import { assertBuildAssignmentReconciled, isInterruptedBuilderRecovery, assertInterruptedSourceSnapshot } from "../buildAssignment.js";
|
|
14
|
-
import { durableHumanDecision, HumanDecisionRequired, HumanDecisionCancelled, servicePendingHumanDecisions } from "../humanDecision.js";
|
|
14
|
+
import { durableHumanDecision, HumanDecisionRequired, HumanDecisionCancelled, reusableCapabilityDecision, servicePendingHumanDecisions } from "../humanDecision.js";
|
|
15
15
|
import { requiresBuildApproval, buildScopeRevision, createBuildApprovalGate, validatePlanApprovalBinding, validateFrozenWorkApprovalBinding } from "../buildApproval.js";
|
|
16
16
|
import { Command, Option } from "commander";
|
|
17
17
|
import { accent, error, sanitizeTerminalText, success, warning } from "../terminalStyle.js";
|
|
@@ -540,7 +540,7 @@ export function buildStartCommand(options = {}) {
|
|
|
540
540
|
.option("--accept-handoff-role <role>", "role owning the pre-staged handoff (builder | qa)", "builder")
|
|
541
541
|
.option("--continue", "resume the most recent logged session for this project")
|
|
542
542
|
.option("-t, --tickets <path>", "path to ticket file (.md, .txt, .yaml, …) — passed to the builder as context")
|
|
543
|
-
.option("-y, --yes", "
|
|
543
|
+
.option("-y, --yes", "never stop to ask: approve the pre-flight plan, plan updates, and capabilities requested by flag (--builder-network, --qa-network)")
|
|
544
544
|
.option("--effort <level>", "reasoning effort level (low|medium|high|xhigh)")
|
|
545
545
|
.option("--fast", "use native fast mode when available; keep the model and reasoning settings")
|
|
546
546
|
.option("--no-qa", "disable per-ticket QA review (enabled by default)")
|
|
@@ -565,13 +565,15 @@ export function buildStartCommand(options = {}) {
|
|
|
565
565
|
.option("--auto-compact-threshold <percent>", "initial Builder context compaction threshold (1-99)")
|
|
566
566
|
.option("--max-branch-depth <n>", "maximum selected branch stack depth", "5")
|
|
567
567
|
.option("--pr-ready", "create ready-for-review PRs instead of draft PRs")
|
|
568
|
-
.option("--builder-network", "request build-scoped builder internet access
|
|
568
|
+
.option("--builder-network", "request build-scoped builder internet access; asks for approval unless --yes; rechecked on resume")
|
|
569
|
+
.option("--qa-network", "allow independent QA network access for this build (e.g. live local services); asks for approval unless --yes; rechecked on resume")
|
|
569
570
|
.option("--builder-approvals", "allow the builder to request explicit per-operation sandbox approvals; QA stays restricted")
|
|
570
571
|
.option("--keep-worktrees", "keep successful ticket worktrees for inspection")
|
|
571
572
|
.option("--dirty-work <mode>", "preserve | include: continue in isolated branches without merging into the dirty checkout")
|
|
572
573
|
.option("--ticket <id>", "select one new ticket, or identify recovery tickets with --resume/--continue/--recover-run", collectTicket, [])
|
|
573
574
|
.option("--skip-delivery-unit <id>", "skip one unfinished delivery unit for this run", collectTicket, [])
|
|
574
575
|
.action(async (project, opts, command) => withBuildInvocation(async () => {
|
|
576
|
+
let waitingForDecision = false;
|
|
575
577
|
try {
|
|
576
578
|
validateStartOptions(opts, false);
|
|
577
579
|
}
|
|
@@ -667,18 +669,26 @@ export function buildStartCommand(options = {}) {
|
|
|
667
669
|
return;
|
|
668
670
|
}
|
|
669
671
|
const requestedNetwork = Boolean(opts.builderNetwork || recoveryRecord?.builderCapabilities?.requestedNetwork);
|
|
672
|
+
// --yes never stops to ask: it approves capabilities the operator
|
|
673
|
+
// requested by flag. A resumed run counts only an explicit --yes.
|
|
674
|
+
const explicitYes = recoveryRecord ? recoveryRecord.recoveryDecision?.planUpdateApprovalSource === "explicit-yes" : Boolean(opts.yes);
|
|
670
675
|
let grantedNetwork = false;
|
|
671
|
-
|
|
676
|
+
let networkDecisionId;
|
|
677
|
+
if (requestedNetwork && explicitYes) {
|
|
678
|
+
grantedNetwork = true;
|
|
679
|
+
console.error("foreman: builder internet for this build approved by --yes (requested with --builder-network)");
|
|
680
|
+
}
|
|
681
|
+
else if (requestedNetwork) {
|
|
672
682
|
const permissionDb = new WorkflowDb(cwd);
|
|
673
683
|
let networkKey;
|
|
674
684
|
try {
|
|
675
|
-
networkKey = permissionDb
|
|
685
|
+
networkKey = reusableCapabilityDecision(permissionDb, supervisedInvocationId, "builder-network:", recoveryRecord?.builderCapabilities?.decisionId) ?? `builder-network:${process.pid}:${randomUUID()}`;
|
|
676
686
|
}
|
|
677
687
|
finally {
|
|
678
688
|
permissionDb.close();
|
|
679
689
|
}
|
|
680
|
-
const networkDecision = await durableHumanDecision({ projectDir: cwd, runId: supervisedInvocationId, key: networkKey,
|
|
681
|
-
prompt: "Allow the builder shell to access the internet for this build? This permits network use by any builder command, not only downloads. Workspace write confinement remains enforced. QA stays offline. Recovery requires approval again
|
|
690
|
+
const networkDecision = await durableHumanDecision({ projectDir: cwd, runId: supervisedInvocationId, key: networkKey, onAnswered: decision => { networkDecisionId = decision.decisionId; },
|
|
691
|
+
prompt: "Allow the builder shell to access the internet for this build? This permits network use by any builder command, not only downloads. Workspace write confinement remains enforced. QA stays offline unless --qa-network is separately approved. Recovery requires approval again unless resumed with --yes.",
|
|
682
692
|
choices: [{ id: "allow-network", label: "Allow builder internet for this build" }, { id: "stay-offline", label: "Stay offline and use a verified local bundle" }],
|
|
683
693
|
operation: async () => {
|
|
684
694
|
const action = await select({ message: "Allow builder internet for this build?", options: [{ value: "stay-offline", label: "Stay offline" }, { value: "allow-network", label: "Allow builder internet for this build" }] });
|
|
@@ -688,7 +698,37 @@ export function buildStartCommand(options = {}) {
|
|
|
688
698
|
return;
|
|
689
699
|
grantedNetwork = networkDecision === "allow-network";
|
|
690
700
|
}
|
|
691
|
-
const builderCapabilities = { requestedNetwork, grantedNetwork, ...(grantedNetwork ? { approvedAt: new Date().toISOString(), grantWorkerPid: process.pid } : {}) };
|
|
701
|
+
const builderCapabilities = { requestedNetwork, grantedNetwork, ...(grantedNetwork ? { approvedAt: new Date().toISOString(), grantWorkerPid: process.pid } : {}), ...(networkDecisionId ? { decisionId: networkDecisionId } : {}) };
|
|
702
|
+
// Independent QA is offline unless the operator grants --qa-network, e.g.
|
|
703
|
+
// so QA can verify a live local service the ticket requires.
|
|
704
|
+
const requestedQaNetwork = Boolean(opts.qaNetwork || recoveryRecord?.qaCapabilities?.requestedNetwork);
|
|
705
|
+
let grantedQaNetwork = false;
|
|
706
|
+
let qaNetworkDecisionId;
|
|
707
|
+
if (requestedQaNetwork && explicitYes) {
|
|
708
|
+
grantedQaNetwork = true;
|
|
709
|
+
console.error("foreman: QA network for this build approved by --yes (requested with --qa-network)");
|
|
710
|
+
}
|
|
711
|
+
else if (requestedQaNetwork) {
|
|
712
|
+
const permissionDb = new WorkflowDb(cwd);
|
|
713
|
+
let qaNetworkKey;
|
|
714
|
+
try {
|
|
715
|
+
qaNetworkKey = reusableCapabilityDecision(permissionDb, supervisedInvocationId, "qa-network:", recoveryRecord?.qaCapabilities?.decisionId) ?? `qa-network:${process.pid}:${randomUUID()}`;
|
|
716
|
+
}
|
|
717
|
+
finally {
|
|
718
|
+
permissionDb.close();
|
|
719
|
+
}
|
|
720
|
+
const qaNetworkDecision = await durableHumanDecision({ projectDir: cwd, runId: supervisedInvocationId, key: qaNetworkKey, onAnswered: decision => { qaNetworkDecisionId = decision.decisionId; },
|
|
721
|
+
prompt: "Allow independent QA shell commands to access the network for this build? This lets QA run live checks (for example against a local service). QA keeps a read-only workspace. Recovery requires approval again unless resumed with --yes.",
|
|
722
|
+
choices: [{ id: "allow-qa-network", label: "Allow QA network for this build" }, { id: "qa-offline", label: "Keep QA offline" }],
|
|
723
|
+
operation: async () => {
|
|
724
|
+
const action = await select({ message: "Allow QA network for this build?", options: [{ value: "qa-offline", label: "Keep QA offline" }, { value: "allow-qa-network", label: "Allow QA network for this build" }] });
|
|
725
|
+
return isCancel(action) ? undefined : action;
|
|
726
|
+
} });
|
|
727
|
+
if (qaNetworkDecision === undefined || isCancel(qaNetworkDecision))
|
|
728
|
+
return;
|
|
729
|
+
grantedQaNetwork = qaNetworkDecision === "allow-qa-network";
|
|
730
|
+
}
|
|
731
|
+
const qaCapabilities = { requestedNetwork: requestedQaNetwork, grantedNetwork: grantedQaNetwork, ...(grantedQaNetwork ? { approvedAt: new Date().toISOString() } : {}), ...(qaNetworkDecisionId ? { decisionId: qaNetworkDecisionId } : {}) };
|
|
692
732
|
const readinessOwner = { project: cwd, runId: supervisedInvocationId, authority: localBuildAuthority(cwd) };
|
|
693
733
|
const autoApprovePlanUpdates = recoveryRecord
|
|
694
734
|
? recoveryRecord.recoveryDecision?.planUpdateApproval === "auto"
|
|
@@ -1101,6 +1141,9 @@ export function buildStartCommand(options = {}) {
|
|
|
1101
1141
|
: base;
|
|
1102
1142
|
const handoffRecoveryOptions = (role) => ({
|
|
1103
1143
|
allowProviderSwitch: true,
|
|
1144
|
+
// --yes never stops to ask: retry with a fresh successor. The handoff
|
|
1145
|
+
// still pauses after three rejected successors.
|
|
1146
|
+
...(explicitYes ? { choose: async () => "retry" } : {}),
|
|
1104
1147
|
desktopNotifications: config.notifications.enabled,
|
|
1105
1148
|
terminalBell: config.notifications.terminal_bell,
|
|
1106
1149
|
onAccepted: (accepted) => {
|
|
@@ -1185,6 +1228,7 @@ export function buildStartCommand(options = {}) {
|
|
|
1185
1228
|
configRoot: cwd,
|
|
1186
1229
|
sessionRole: "qa",
|
|
1187
1230
|
sessionStream: "qa",
|
|
1231
|
+
networkAccess: grantedQaNetwork,
|
|
1188
1232
|
runtimeExecutable: ready.executable,
|
|
1189
1233
|
runtimePhase: "qa",
|
|
1190
1234
|
preparationTimeoutMs: frozenPolicy.runtimeDeadlines?.preparation_ms,
|
|
@@ -1456,6 +1500,7 @@ export function buildStartCommand(options = {}) {
|
|
|
1456
1500
|
configRoot: cwd,
|
|
1457
1501
|
sessionRole: "qa",
|
|
1458
1502
|
sessionStream: "qa",
|
|
1503
|
+
networkAccess: grantedQaNetwork,
|
|
1459
1504
|
runtimeExecutable: ready.executable,
|
|
1460
1505
|
runtimePhase: "qa",
|
|
1461
1506
|
preparationTimeoutMs: frozenPolicy.runtimeDeadlines?.preparation_ms,
|
|
@@ -2155,7 +2200,7 @@ export function buildStartCommand(options = {}) {
|
|
|
2155
2200
|
&& (opts.qaRevision !== undefined || pendingQaRecoveryPacket(cwd, recoveryRecord.runId, requestedTicketIds[0])));
|
|
2156
2201
|
let masterRun = recoveryRecord ? resumeBuildRun(cwd, recoveryRecord.runId, { ...(branchQaOnlyRecovery ? {} : { builder: capturedBranchBuilder, builderSessionId: firstResumeRef?.sessionId ?? null, builderSessionRef: firstResumeRef ?? null }), qa: capturedBranchQa, expectedRecoveryDecisionDigest: opts.recoveryDecisionDigest }) : createBuildRun({
|
|
2157
2202
|
runId: invocationRunId,
|
|
2158
|
-
builderCapabilities, tickets: plan.nodes.map((node) => node.ticket.id), deliveryUnit: selectedStacks.length ? selectedStacks.map((stack) => stack.id).join(",") : deliveryRun?.unit.id,
|
|
2203
|
+
builderCapabilities, qaCapabilities, tickets: plan.nodes.map((node) => node.ticket.id), deliveryUnit: selectedStacks.length ? selectedStacks.map((stack) => stack.id).join(",") : deliveryRun?.unit.id,
|
|
2159
2204
|
repositoryRoot: cwd, branchMode: branchPresentation.allocationMode, baseRef: plan.baseRef, builder: capturedBranchBuilder, qa: capturedBranchQa,
|
|
2160
2205
|
frozenPolicy, autonomyProfile, qaEnabled,
|
|
2161
2206
|
runDecisions: { dirtyWork, workMode: "branch-per-ticket", workModeSource, branchPrefix, branchPrefixSource: resolvedPrefix.source, autoCompactThresholdPercent: capturedBranchBuilder.auto_compact_threshold_percent ?? 65, thresholdSource: thresholdOverride === undefined ? "project" : "cli" },
|
|
@@ -2189,7 +2234,7 @@ export function buildStartCommand(options = {}) {
|
|
|
2189
2234
|
}
|
|
2190
2235
|
for (const node of plan.nodes)
|
|
2191
2236
|
admitApprovedTicket(cwd, masterRun.runId, node.ticket.id, buildScopeRevision(cwd), `build invocation ${invocationRunId}: approved branch plan`);
|
|
2192
|
-
masterRun = checkpointBuildRun(cwd, masterRun, "capabilities-resolved", { builderCapabilities });
|
|
2237
|
+
masterRun = checkpointBuildRun(cwd, masterRun, "capabilities-resolved", { builderCapabilities, qaCapabilities });
|
|
2193
2238
|
const qaResumedRecovery = recoveryRecord && !isInterruptedBuilderRecovery(recoveryRecord) ? pendingQaRecoveryPacket(cwd, masterRun.runId, requestedTicketIds[0]) : undefined;
|
|
2194
2239
|
let qaProtocolResumeTicket;
|
|
2195
2240
|
if (recoveryRecord && !qaResumedRecovery && !isInterruptedBuilderRecovery(recoveryRecord)) {
|
|
@@ -2513,11 +2558,11 @@ export function buildStartCommand(options = {}) {
|
|
|
2513
2558
|
&& (opts.qaRevision !== undefined || pendingQaRecoveryPacket(cwd, recoveryRecord.runId, requestedTicketIds[0])));
|
|
2514
2559
|
let buildRun = recoveryRecord ? resumeBuildRun(cwd, recoveryRecord.runId, { ...(qaOnlyRecovery ? {} : { builder: capturedBuilder, builderSessionId: resumeSessionId ?? null, builderSessionRef: resumeSessionRef ?? null }), qa: capturedQa, expectedRecoveryDecisionDigest: opts.recoveryDecisionDigest }) : createBuildRun({
|
|
2515
2560
|
runId: invocationRunId,
|
|
2516
|
-
tickets: [...(executionTickets ?? [])], builderCapabilities, authorizedBatch: { tickets: [...(executionTickets ?? [])], requestedSteps: steps, scopeRevision: buildScopeRevision(cwd, approvalConsequences), startedTickets: [] }, repositoryRoot: cwd, branchMode: "current", baseRef: opts.base ?? loadTicketSetupConfig(cwd)?.build.base_branch, builder: capturedBuilder, qa: capturedQa,
|
|
2561
|
+
tickets: [...(executionTickets ?? [])], builderCapabilities, qaCapabilities, authorizedBatch: { tickets: [...(executionTickets ?? [])], requestedSteps: steps, scopeRevision: buildScopeRevision(cwd, approvalConsequences), startedTickets: [] }, repositoryRoot: cwd, branchMode: "current", baseRef: opts.base ?? loadTicketSetupConfig(cwd)?.build.base_branch, builder: capturedBuilder, qa: capturedQa,
|
|
2517
2562
|
frozenPolicy, autonomyProfile, qaEnabled,
|
|
2518
2563
|
runDecisions: { workMode: "current", workModeSource, branchPrefix, branchPrefixSource: resolvedPrefix.source, autoCompactThresholdPercent: capturedBuilder.auto_compact_threshold_percent ?? 65, thresholdSource: thresholdOverride === undefined ? "project" : "cli" },
|
|
2519
2564
|
});
|
|
2520
|
-
buildRun = checkpointBuildRun(cwd, buildRun, "capabilities-resolved", { builderCapabilities });
|
|
2565
|
+
buildRun = checkpointBuildRun(cwd, buildRun, "capabilities-resolved", { builderCapabilities, qaCapabilities });
|
|
2521
2566
|
activeObserver?.store.attachExecutionLease(activeObserver.executionId, readCurrentWorkflowLease(cwd)?.generation);
|
|
2522
2567
|
activeContinuityRunId = buildRun.runId;
|
|
2523
2568
|
publishActivatedBuilderSession = ref => { buildRun = persistCurrentBuildSession(cwd, buildRun, "builder", ref); };
|
|
@@ -2988,11 +3033,16 @@ export function buildStartCommand(options = {}) {
|
|
|
2988
3033
|
if (!(error instanceof HumanDecisionRequired))
|
|
2989
3034
|
throw error;
|
|
2990
3035
|
console.error(error.message);
|
|
2991
|
-
|
|
3036
|
+
waitingForDecision = true;
|
|
2992
3037
|
}
|
|
2993
3038
|
finally {
|
|
2994
3039
|
finishStartAdmission(cwd, supervisedInvocationId);
|
|
2995
3040
|
}
|
|
3041
|
+
// Exit explicitly: supervisor IPC, heartbeats and runtime handles keep a
|
|
3042
|
+
// supervised worker alive, so it would otherwise wait forever while its
|
|
3043
|
+
// supervisor waits for it, and `rafi resume` refuses the "live" owner.
|
|
3044
|
+
if (waitingForDecision)
|
|
3045
|
+
process.exit(2);
|
|
2996
3046
|
}));
|
|
2997
3047
|
}
|
|
2998
3048
|
function ticketSizeBucket(projectDir, ticketIds) {
|
package/dist/cli/tickets.d.ts
CHANGED
|
@@ -16,7 +16,6 @@ export interface RiskNoteRepairProposal {
|
|
|
16
16
|
rollback: string;
|
|
17
17
|
}>;
|
|
18
18
|
}
|
|
19
|
-
export declare function extractRiskNoteRepairProposal(output: string): RiskNoteRepairProposal;
|
|
20
19
|
/** Validate a deliberately narrow repair: only exactly the missing rollback fields may change. */
|
|
21
20
|
export declare function applyRiskNoteRepairProposal(tickets: import("../tickets/ticketSchema.js").TicketDef[], proposal: RiskNoteRepairProposal): import("../tickets/ticketSchema.js").TicketDef[];
|
|
22
21
|
export declare function buildPopulateInstruction(approvedPlanPath: string, contextSources?: string[], progressDoc?: string): string;
|
package/dist/cli/tickets.js
CHANGED
|
@@ -5,7 +5,7 @@ import { accent, error, sanitizeTerminalText, success, warning } from "../termin
|
|
|
5
5
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
6
6
|
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
7
7
|
import { parse as parseYaml } from "yaml";
|
|
8
|
-
import { riskNoteRepairOutput
|
|
8
|
+
import { riskNoteRepairOutput } from "rafi-spec";
|
|
9
9
|
import { select, text, confirm, isCancel, multiselect } from "@clack/prompts";
|
|
10
10
|
import { loadConfig } from "../config.js";
|
|
11
11
|
import { Log } from "../log.js";
|
|
@@ -116,26 +116,6 @@ function optionWasProvided(command, name) {
|
|
|
116
116
|
}
|
|
117
117
|
const TICKET_POPULATE_ROLE = "ticket-maker";
|
|
118
118
|
const RAFI_CONFIG_FILES = ["rafi-config.yaml", "project.yaml"];
|
|
119
|
-
const RISK_NOTE_REPAIR_START = "RAFI_RISK_NOTE_REPAIR_START";
|
|
120
|
-
const RISK_NOTE_REPAIR_END = "RAFI_RISK_NOTE_REPAIR_END";
|
|
121
|
-
export function extractRiskNoteRepairProposal(output) {
|
|
122
|
-
const start = output.lastIndexOf(RISK_NOTE_REPAIR_START);
|
|
123
|
-
const end = output.lastIndexOf(RISK_NOTE_REPAIR_END);
|
|
124
|
-
if (start < 0 || end <= start)
|
|
125
|
-
throw new Error(`Ticket Maker repair output is missing ${RISK_NOTE_REPAIR_START}/${RISK_NOTE_REPAIR_END}`);
|
|
126
|
-
const raw = output.slice(start + RISK_NOTE_REPAIR_START.length, end).trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "");
|
|
127
|
-
let parsed;
|
|
128
|
-
try {
|
|
129
|
-
parsed = JSON.parse(raw);
|
|
130
|
-
}
|
|
131
|
-
catch (error) {
|
|
132
|
-
throw new Error(`Ticket Maker risk-note repair is invalid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
133
|
-
}
|
|
134
|
-
const validated = riskNoteRepairProposalV1Schema.safeParse(parsed);
|
|
135
|
-
if (!validated.success)
|
|
136
|
-
throw new Error(`Ticket Maker risk-note repair contract validation failed:\n${zodIssues(validated.error).map((issue) => `- ${issue}`).join("\n")}`);
|
|
137
|
-
return validated.data;
|
|
138
|
-
}
|
|
139
119
|
/** Validate a deliberately narrow repair: only exactly the missing rollback fields may change. */
|
|
140
120
|
export function applyRiskNoteRepairProposal(tickets, proposal) {
|
|
141
121
|
const targets = tickets.filter(ticket => ticket.risk !== "Low" && !hasRollbackOrMitigationPlan(ticket.rollback));
|
|
@@ -1475,7 +1455,16 @@ export async function cmdPopulateCli(opts) {
|
|
|
1475
1455
|
checkpoint: scope => { planningRecord = checkpointInterview(dir, planningRecord, { decisions: { ...planningRecord.decisions, populationArtifact: scope } }); },
|
|
1476
1456
|
dispatch: (instruction, policy) => foreman.runInstruction(instruction, policy),
|
|
1477
1457
|
});
|
|
1478
|
-
const
|
|
1458
|
+
const populateInstruction = buildPopulateInstruction(approvedPlan.path, contextSources, ticketsConfig.paths.progressDoc);
|
|
1459
|
+
let initialPopulation;
|
|
1460
|
+
try {
|
|
1461
|
+
initialPopulation = await population(populateInstruction, 0);
|
|
1462
|
+
}
|
|
1463
|
+
catch (error) {
|
|
1464
|
+
// One automatic restart of a failed population before stopping for a human.
|
|
1465
|
+
console.log(`foreman tickets: ticket population failed (${String(error).slice(0, 200)}); restarting it once from the first page.`);
|
|
1466
|
+
initialPopulation = await population(populateInstruction, 0);
|
|
1467
|
+
}
|
|
1479
1468
|
let turn = initialPopulation.turn;
|
|
1480
1469
|
planningRecord = checkpointInterview(dir, planningRecord, { runtime: { runtime: roleBuilder.runtime, model: roleBuilder.model, sessionId: builder.sessionId() }, decisions: { ...planningRecord.decisions, proposalResponse: turn.result.text, structuredResponse: turn.result.structured } });
|
|
1481
1470
|
log.write("ticket-populate", {
|
package/dist/continuity.d.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import type { ContinuityDelta, ResolvedAgentSettings } from "rafi-spec";
|
|
2
2
|
import type { BuilderAdapter, CompactResult, ContextUsage, NativeAutoCompactionPolicy, NativeCompaction, ProviderSessionUsage, ProviderSettingSwitch, RuntimeFailure, TurnResult } from "./adapters/types.js";
|
|
3
3
|
import type { BuilderHandoffRequest } from "./handoffs.js";
|
|
4
|
-
export declare const CONTINUITY_MARKER = "RAFI_CONTINUITY_DELTA:";
|
|
5
4
|
export declare class ContinuityValidationError extends Error {
|
|
6
5
|
readonly problems: string[];
|
|
7
6
|
constructor(problems: string[]);
|
|
@@ -27,7 +26,6 @@ export declare const CONTINUITY_REQUIRED_INSTRUCTION = "Include the required bou
|
|
|
27
26
|
export declare function validateContinuityDelta(value: unknown): ContinuityDelta;
|
|
28
27
|
export declare function mergeContinuityDeltas(deltas: readonly ContinuityDelta[]): ContinuityDelta;
|
|
29
28
|
export declare function baselineContinuityDelta(nextAction?: string): ContinuityDelta;
|
|
30
|
-
export declare function continuityInstruction(role?: "builder" | "qa"): string;
|
|
31
29
|
export interface ContinuityAdapterOptions {
|
|
32
30
|
adapter: BuilderAdapter;
|
|
33
31
|
projectDir: string;
|
package/dist/continuity.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { persistedProviderWireContract } from "rafi-spec";
|
|
2
|
+
import { markDispatchNotSent } from "./dispatchNotSent.js";
|
|
2
3
|
import { dispatchWithGraphAccess, withGraphSessionAccess } from "./graph/session.js";
|
|
3
4
|
import { localBuildAuthority } from "./buildAdmission.js";
|
|
4
5
|
import { randomUUID, createHash } from "node:crypto";
|
|
@@ -9,7 +10,6 @@ import { nativePolicy, operationInstruction, journalStructuredDispatch, continua
|
|
|
9
10
|
import { WorkflowDb } from "./workflowDb.js";
|
|
10
11
|
import { assertSealedDispatch, withFreshRecoveryValidation } from "./structuredDispatchAuthority.js";
|
|
11
12
|
import { verifyPersistedDispatch } from "./structuredDispatchRecord.js";
|
|
12
|
-
export const CONTINUITY_MARKER = "RAFI_CONTINUITY_DELTA:";
|
|
13
13
|
export class ContinuityValidationError extends Error {
|
|
14
14
|
problems;
|
|
15
15
|
constructor(problems) {
|
|
@@ -93,15 +93,6 @@ export function mergeContinuityDeltas(deltas) {
|
|
|
93
93
|
export function baselineContinuityDelta(nextAction = "Await the first role action.") {
|
|
94
94
|
return { version: 1, decisions: [], constraints: [], discoveries: [], completedActions: [], evidence: [], failures: [], blockers: [], openWork: [], nextAction };
|
|
95
95
|
}
|
|
96
|
-
export function continuityInstruction(role) {
|
|
97
|
-
return [
|
|
98
|
-
"Before your final STEP_STATUS line (or as the final line when STEP_STATUS is not requested), emit exactly one single-line continuity record:",
|
|
99
|
-
`${CONTINUITY_MARKER} {"version":1,"decisions":[],"constraints":[],"discoveries":[],"completedActions":[],"evidence":[],"failures":[],"blockers":[],"openWork":[],"nextAction":"specific next action"}`,
|
|
100
|
-
"The continuity record is JSON. A requested STEP_STATUS line is separate plain text: never add JSON braces, a trailing comma, or a code fence to that status line.",
|
|
101
|
-
...(role === "qa" ? ["QA protocol: this continuity record is required even when the review requests only a report or status. Put it before RAFI_QA_FAILURE_REPORT_START when returning a failure envelope, outside the report JSON. Rafi strips the record before validating the report, so keep the report envelope immediately followed by STEP_STATUS."] : []),
|
|
102
|
-
"Keep it concise and cumulative for facts learned this turn. Do not include credentials, hidden reasoning, or raw transcripts.",
|
|
103
|
-
].join("\n");
|
|
104
|
-
}
|
|
105
96
|
/** Enforces a durable role checkpoint after every completed provider turn. */
|
|
106
97
|
export class ContinuityAdapter {
|
|
107
98
|
options;
|
|
@@ -201,30 +192,35 @@ export class ContinuityAdapter {
|
|
|
201
192
|
const beforeSession = this.adapter.sessionRef?.();
|
|
202
193
|
let original;
|
|
203
194
|
try {
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
195
|
+
try {
|
|
196
|
+
journal.atomic(() => {
|
|
197
|
+
if (this.buildAuthority)
|
|
198
|
+
journal.assertBuildAdmission(this.buildAuthority);
|
|
199
|
+
else if (journal.buildAdmission())
|
|
200
|
+
throw new ContinuityRecoveryRequiredError(this.options.runId, this.options.role, "dispatch requires original build admission authority");
|
|
201
|
+
if (process.send && process.env.RAFI_BUILD_WORKER_RUN === this.options.runId) {
|
|
202
|
+
const supervisor = journal.supervisorState(this.options.runId);
|
|
203
|
+
if (!process.connected || supervisor?.status !== "running" || supervisor.workerGeneration !== Number(process.env.RAFI_BUILD_WORKER_GENERATION))
|
|
204
|
+
throw new ContinuityRecoveryRequiredError(this.options.runId, this.options.role, "supervised worker no longer owns dispatch authority");
|
|
205
|
+
}
|
|
206
|
+
const owner = journal.roleMutationLease(this.options.runId, this.options.role);
|
|
207
|
+
const ref = this.adapter.sessionRef?.();
|
|
208
|
+
const session = ref?.sessionId ?? this.adapter.sessionId();
|
|
209
|
+
if (!(this.validatingFreshRecovery && this.recoveryLeasePending && policy?.responseOnly) && owner && session && (owner.providerSessionId !== session || (owner.sessionKey && ref && owner.sessionKey !== providerSessionKey(ref)))) {
|
|
210
|
+
throw new ContinuityRecoveryRequiredError(this.options.runId, this.options.role, "provider session no longer owns role dispatch authority");
|
|
211
|
+
}
|
|
212
|
+
if (journal.unresolvedRoleDispatches(this.options.runId, this.options.role).filter(item => item.idempotencyKey !== dispatchId).length)
|
|
213
|
+
throw new ContinuityRecoveryRequiredError(this.options.runId, this.options.role, "a previous dispatched turn has no durable completion; reconcile it before sending more work");
|
|
214
|
+
if (!policy?.selectedDispatch)
|
|
215
|
+
journal.planOperation({ runId: this.options.runId, idempotencyKey: dispatchId, kind: "provider-dispatch", intent: { role: this.options.role, sessionRef: this.adapter.sessionRef?.(), instructionDigest: sha(instruction), instructionBytes: Buffer.byteLength(instruction) } });
|
|
216
|
+
if (!policy?.selectedDispatch)
|
|
217
|
+
journal.updateOperation(dispatchId, "in_progress");
|
|
218
|
+
journal.appendContinuityEvent({ runId: this.options.runId, role: "host", kind: "turn_started", payload: { role: this.options.role, dispatchId, instructionDigest: sha(instruction), instructionBytes: Buffer.byteLength(instruction) }, authoritativeStateRevision: this.revision() });
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
catch (error) {
|
|
222
|
+
throw markDispatchNotSent(error);
|
|
223
|
+
}
|
|
228
224
|
try {
|
|
229
225
|
original = await dispatchWithGraphAccess(this.options.projectDir, this.adapter, providerInstruction, policy, (text, p) => this.adapter.sendTurn(text, p));
|
|
230
226
|
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Tag an error raised before any provider transport began, so the journal
|
|
2
|
+
* records a known unsent turn instead of an unknown outcome. */
|
|
3
|
+
export declare function markDispatchNotSent(error: unknown): unknown;
|
|
4
|
+
export declare function dispatchWasNotSent(error: unknown): boolean;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Tag an error raised before any provider transport began, so the journal
|
|
2
|
+
* records a known unsent turn instead of an unknown outcome. */
|
|
3
|
+
export function markDispatchNotSent(error) {
|
|
4
|
+
if (error && typeof error === "object")
|
|
5
|
+
error.dispatchNotSent = true;
|
|
6
|
+
return error;
|
|
7
|
+
}
|
|
8
|
+
export function dispatchWasNotSent(error) {
|
|
9
|
+
return Boolean(error && typeof error === "object" && error.dispatchNotSent);
|
|
10
|
+
}
|
package/dist/foreman.d.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { type QaNonconvergenceContext, type QaNonconvergenceDecision, type QaRep
|
|
|
7
7
|
import type { SessionStrategy } from "rafi-spec";
|
|
8
8
|
import type { RunObserver } from "./observability.js";
|
|
9
9
|
import type { QaRecoveryPacket } from "./qaRecovery.js";
|
|
10
|
-
/**
|
|
10
|
+
/** A role turn's outcome, derived from its native structured result. */
|
|
11
11
|
export interface StepStatus {
|
|
12
12
|
kind: "done" | "blocked" | "plan_complete" | "needs_input" | "qa_pass" | "qa_fail" | "unknown";
|
|
13
13
|
summary?: string;
|
|
@@ -21,17 +21,10 @@ export interface StepStatus {
|
|
|
21
21
|
error?: string;
|
|
22
22
|
}
|
|
23
23
|
/**
|
|
24
|
-
* Workflow-specific completion is opt-in. Callers may
|
|
25
|
-
* structured response before the generic
|
|
24
|
+
* Workflow-specific completion is opt-in. Callers may interpret a valid
|
|
25
|
+
* structured response before the generic native result handling runs.
|
|
26
26
|
*/
|
|
27
27
|
export type InstructionCompletion = (text: string) => StepStatus | undefined;
|
|
28
|
-
/**
|
|
29
|
-
* Pull the STEP_STATUS marker out of a turn's final text.
|
|
30
|
-
* Format: `STEP_STATUS: <kind> | key="value" key="value"`.
|
|
31
|
-
*/
|
|
32
|
-
export declare function parseStepStatus(text: string, options?: {
|
|
33
|
-
requireFinalLine?: boolean;
|
|
34
|
-
}): StepStatus;
|
|
35
28
|
/** Heuristic: did a marker-less turn end by asking the human something? */
|
|
36
29
|
export declare function looksLikeQuestion(text: string): boolean;
|
|
37
30
|
export { MARKER_SPEC } from "./markers.js";
|
|
@@ -39,13 +32,13 @@ export { QA_MARKER_SPEC } from "./markers.js";
|
|
|
39
32
|
/** Instruction sent on the first turn of a batch. */
|
|
40
33
|
export declare function buildPrimer(n: number, trackerPath?: string, ticketsEnabled?: boolean, preferredTicketId?: string): string;
|
|
41
34
|
/** Instruction sent on turns 2–N. */
|
|
42
|
-
export declare function buildNextStepInstruction(i: number, n: number): string;
|
|
35
|
+
export declare function buildNextStepInstruction(i: number, n: number, ticketsEnabled?: boolean): string;
|
|
43
36
|
/** QA review turn: ask the builder to triple-check the ticket or step it just completed. */
|
|
44
37
|
export declare function buildQaInstruction(): string;
|
|
45
38
|
/** Follow-up turn after qa_fail: have the builder implement the listed fixes. */
|
|
46
39
|
export declare function buildQaFixInstruction(issues: string): string;
|
|
47
40
|
/** Pre-flight planning turn: ask the builder to list its next N steps without implementing anything. */
|
|
48
|
-
export declare function buildPlanningTurn(n: number, ticketsContent?: string, preferredTicketId?: string): string;
|
|
41
|
+
export declare function buildPlanningTurn(n: number, ticketsContent?: string, preferredTicketId?: string, ticketsEnabled?: boolean): string;
|
|
49
42
|
/** Builds the permission callback: classify, log, allow or escalate. */
|
|
50
43
|
export declare function createPermissionHandler(policy: PermissionPolicy, log: Log, opts?: {
|
|
51
44
|
interactive?: boolean;
|