ai-foreman 1.7.11 → 1.7.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -0
- package/dist/activity.d.ts +5 -1
- package/dist/activity.js +1 -1
- package/dist/adapters/claude.d.ts +7 -0
- package/dist/adapters/claude.js +133 -7
- package/dist/adapters/codex.d.ts +5 -0
- package/dist/adapters/codex.js +121 -11
- package/dist/adapters/recovering.d.ts +2 -0
- package/dist/adapters/recovering.js +11 -1
- package/dist/adapters/types.d.ts +47 -1
- package/dist/agentRun.d.ts +2 -0
- package/dist/agentRun.js +8 -2
- package/dist/branch/resume.js +11 -1
- package/dist/branch/runner.d.ts +12 -3
- package/dist/branch/runner.js +131 -67
- package/dist/buildRuns.d.ts +6 -2
- package/dist/buildRuns.js +76 -32
- package/dist/cli/doctor.d.ts +7 -0
- package/dist/cli/doctor.js +84 -1
- package/dist/cli/manager.d.ts +17 -0
- package/dist/cli/manager.js +135 -0
- package/dist/cli/recovery.d.ts +4 -0
- package/dist/cli/recovery.js +80 -0
- package/dist/cli/start.js +446 -142
- package/dist/cli/status.js +13 -2
- package/dist/cli/tickets.js +27 -9
- package/dist/config.d.ts +4 -0
- package/dist/config.js +28 -1
- package/dist/continuity.js +14 -9
- package/dist/diagnostics.d.ts +25 -0
- package/dist/diagnostics.js +428 -0
- package/dist/foreman.d.ts +26 -6
- package/dist/foreman.js +147 -104
- package/dist/handoffs.d.ts +35 -3
- package/dist/handoffs.js +155 -17
- package/dist/index.js +6 -0
- package/dist/log.d.ts +3 -3
- package/dist/log.js +4 -1
- package/dist/managerPacket.d.ts +25 -0
- package/dist/managerPacket.js +83 -0
- package/dist/markers.d.ts +1 -1
- package/dist/markers.js +4 -1
- package/dist/notify.d.ts +4 -0
- package/dist/notify.js +15 -0
- package/dist/observability.d.ts +248 -0
- package/dist/observability.js +1018 -0
- package/dist/planChanges.d.ts +4 -0
- package/dist/planChanges.js +16 -0
- package/dist/projectDiagnostics.d.ts +27 -0
- package/dist/projectDiagnostics.js +407 -0
- package/dist/qaRecovery.d.ts +140 -0
- package/dist/qaRecovery.js +595 -0
- package/dist/qaReview.d.ts +133 -14
- package/dist/qaReview.js +984 -59
- package/dist/qaSnapshot.d.ts +31 -5
- package/dist/qaSnapshot.js +209 -134
- package/dist/recoveryPolicy.d.ts +37 -0
- package/dist/recoveryPolicy.js +251 -0
- package/dist/roles.js +1 -0
- package/dist/supervisor.d.ts +40 -0
- package/dist/supervisor.js +102 -0
- package/dist/ticketHistoryReader.d.ts +19 -0
- package/dist/ticketHistoryReader.js +58 -0
- package/dist/tickets/details.d.ts +8 -0
- package/dist/tickets/details.js +59 -11
- package/dist/tickets/output.d.ts +5 -0
- package/dist/tickets/output.js +73 -0
- package/dist/tickets/stateDb.d.ts +1 -0
- package/dist/tickets/stateDb.js +5 -0
- package/dist/workflowDb.d.ts +31 -2
- package/dist/workflowDb.js +192 -23
- package/dist/workflowReader.d.ts +42 -0
- package/dist/workflowReader.js +171 -0
- package/foreman.yaml +13 -0
- package/package.json +51 -3
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
3
|
+
import { hostname } from "node:os";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
import { readBuildRuns } from "./buildRuns.js";
|
|
6
|
+
import { diagnosticDigest, ObservabilityReader, sanitizeDiagnosticValue } from "./observability.js";
|
|
7
|
+
import { WorkflowReader } from "./workflowReader.js";
|
|
8
|
+
import { isTicketsInitialized, loadTicketsConfig, resolveTicketPaths } from "./tickets/config.js";
|
|
9
|
+
import { loadTickets } from "./tickets/ticketLoader.js";
|
|
10
|
+
const WAIT_KINDS = new Set(["user_wait", "dependency_wait", "external_ci_wait"]);
|
|
11
|
+
const ALLOWED_GIT = new Set(["status", "rev-parse", "rev-list", "diff"]);
|
|
12
|
+
export function selectDiagnosticRun(projectDir, requestedRunId) {
|
|
13
|
+
const runs = readBuildRuns(projectDir);
|
|
14
|
+
if (requestedRunId) {
|
|
15
|
+
const exact = runs.find(run => run.runId === requestedRunId);
|
|
16
|
+
if (!exact)
|
|
17
|
+
throw new Error(`build run not found: ${requestedRunId}`);
|
|
18
|
+
return exact;
|
|
19
|
+
}
|
|
20
|
+
const workflow = new WorkflowReader(projectDir);
|
|
21
|
+
try {
|
|
22
|
+
const lease = workflow.currentLease();
|
|
23
|
+
const leased = lease && evaluateLease(lease, new Date()).live ? runs.find(run => run.runId === lease.runId) : undefined;
|
|
24
|
+
if (leased)
|
|
25
|
+
return leased;
|
|
26
|
+
}
|
|
27
|
+
finally {
|
|
28
|
+
workflow.close();
|
|
29
|
+
}
|
|
30
|
+
const latest = runs[0];
|
|
31
|
+
if (!latest)
|
|
32
|
+
throw new Error("no build runs found for this project");
|
|
33
|
+
return latest;
|
|
34
|
+
}
|
|
35
|
+
/** Collects and calculates a canonical report. The provider receives this report, never raw database rows. */
|
|
36
|
+
export function collectManagerDiagnostics(projectDir, options = {}) {
|
|
37
|
+
const root = resolve(projectDir);
|
|
38
|
+
const now = options.now ?? new Date();
|
|
39
|
+
const run = selectDiagnosticRun(root, options.runId);
|
|
40
|
+
const observable = new ObservabilityReader(root);
|
|
41
|
+
const workflow = new WorkflowReader(root);
|
|
42
|
+
const sources = {};
|
|
43
|
+
const evidence = [];
|
|
44
|
+
try {
|
|
45
|
+
const schema = observable.schemaVersion();
|
|
46
|
+
sources.observability = source("observability", schema === 1 || schema === 2 ? "available" : observable.available() ? "partial" : "unavailable", now, schema === 1 || schema === 2 ? undefined : observable.available() ? `unsupported schema ${schema ?? "unknown"}` : "observability database is absent");
|
|
47
|
+
sources.recovery = source("recovery", workflow.available() ? "available" : "unavailable", now, workflow.available() ? undefined : "recovery database is absent");
|
|
48
|
+
sources.build_snapshot = source("build_snapshot", "available", now);
|
|
49
|
+
sources.ticket_definitions = source("ticket_definitions", existsSync(join(root, ".tickets", "tickets.yaml")) ? "available" : "not_applicable", now);
|
|
50
|
+
const spans = observable.spans(run.runId);
|
|
51
|
+
const retainedSpanCount = spans.length;
|
|
52
|
+
const currentState = reconcileCurrentState(observable.currentState(run.runId), spans, now);
|
|
53
|
+
const executions = observable.executions(run.runId);
|
|
54
|
+
const workflowRun = workflow.getRun(run.runId);
|
|
55
|
+
const workflowEvents = workflow.events(run.runId);
|
|
56
|
+
const issues = workflow.issues(run.runId);
|
|
57
|
+
const operations = workflow.operations(run.runId);
|
|
58
|
+
const lease = workflow.currentLease();
|
|
59
|
+
const leaseEvidence = evaluateLease(lease?.runId === run.runId ? lease : undefined, now);
|
|
60
|
+
const continuity = workflow.continuityHeads(run.runId);
|
|
61
|
+
sources.continuity = source("continuity", continuity.length ? "available" : workflow.available() ? "partial" : "unavailable", now, continuity.length ? undefined : "no continuity head is recorded for this run");
|
|
62
|
+
for (const head of continuity)
|
|
63
|
+
evidence.push({ evidenceId: `continuity-${head.role}`, source: "continuity", kind: "continuity_head",
|
|
64
|
+
summary: `${head.role} continuity is ${head.state} at sequence ${head.sequence}`, observedAt: head.updatedAt });
|
|
65
|
+
if (!spans.length) {
|
|
66
|
+
const legacy = readLegacyLifecycle(root, run, options.maxDetailSpans ?? 50);
|
|
67
|
+
spans.push(...legacy.spans);
|
|
68
|
+
sources.legacy_jsonl = source("legacy_jsonl", legacy.found ? "partial" : "unavailable", now, legacy.found ? "legacy lifecycle timing is incomplete" : "no correlated legacy lifecycle records found");
|
|
69
|
+
}
|
|
70
|
+
else
|
|
71
|
+
sources.legacy_jsonl = source("legacy_jsonl", "not_applicable", now);
|
|
72
|
+
const storedSummary = observable.runSummaries({ runIds: [run.runId], limit: 1 })[0];
|
|
73
|
+
if (leaseEvidence.live) {
|
|
74
|
+
const git = collectGit(root, run.repository.worktree, options.commandRunner ?? defaultCommandRunner);
|
|
75
|
+
sources.git = source("git", git.state, now, git.detail);
|
|
76
|
+
for (const item of git.evidence)
|
|
77
|
+
evidence.push(item);
|
|
78
|
+
}
|
|
79
|
+
else if (storedSummary?.git) {
|
|
80
|
+
sources.git = source("git", "available", now, "saved terminal Git summary; no unrelated current repository state was queried");
|
|
81
|
+
evidence.push({ evidenceId: "git-summary", source: "git", kind: "git", summary: `saved branch=${storedSummary.git.branch ?? "unknown"}, head=${storedSummary.git.terminalHead?.slice(0, 12) ?? "unknown"}, commits=${storedSummary.git.commitCount ?? "unavailable"}, changedPaths=${storedSummary.git.changedPathCount ?? "unavailable"}`, observedAt: storedSummary.git.observedAt });
|
|
82
|
+
if (storedSummary.git.pullRequest)
|
|
83
|
+
evidence.push({ evidenceId: `external-${storedSummary.git.pullRequest.provider}-${storedSummary.git.pullRequest.id}`, source: "external", kind: storedSummary.git.pullRequest.provider === "github" ? "pull_request" : "merge_request", summary: `saved ${storedSummary.git.pullRequest.provider} review ${storedSummary.git.pullRequest.id}, state=${storedSummary.git.pullRequest.state ?? "unknown"}`, observedAt: storedSummary.git.observedAt });
|
|
84
|
+
}
|
|
85
|
+
else
|
|
86
|
+
sources.git = source("git", "partial", now, "no saved terminal Git summary; current repository state was not attributed to this historical run");
|
|
87
|
+
const shouldExternal = options.external === "on" || (options.external !== "off" && /\b(?:pr|pull request|mr|merge request|ci|pipeline|check|dependency|waiting)\b/i.test(`${options.question ?? ""} ${run.checkpoint}`));
|
|
88
|
+
const external = shouldExternal && leaseEvidence.live
|
|
89
|
+
? collectExternal(run.repository.worktree, options.commandRunner ?? defaultCommandRunner, now)
|
|
90
|
+
: shouldExternal && storedSummary?.git?.pullRequest
|
|
91
|
+
? { state: "available", detail: "saved historical external evidence", evidence: [] }
|
|
92
|
+
: { state: shouldExternal ? "partial" : "not_applicable", detail: shouldExternal ? "live external refresh is limited to a verified active run; saved historical evidence is unavailable" : undefined, evidence: [] };
|
|
93
|
+
sources.external = source("external", external.state, now, external.detail);
|
|
94
|
+
evidence.push(...external.evidence);
|
|
95
|
+
const terminalEnd = run.completedAt ?? (["completed", "failed", "superseded"].includes(run.status) ? run.updatedAt : undefined);
|
|
96
|
+
const observationEnd = leaseEvidence.live ? now : new Date(terminalEnd ?? run.updatedAt);
|
|
97
|
+
const calendarAgeMs = Math.max(0, observationEnd.getTime() - new Date(run.createdAt).getTime());
|
|
98
|
+
const executionIntervals = executions.length
|
|
99
|
+
? executions.map(item => interval(item.startedAt, item.endedAt ?? observationEnd.toISOString(), observationEnd))
|
|
100
|
+
: leaseEvidence.live ? [interval(run.createdAt, undefined, now)] : [interval(run.createdAt, terminalEnd ?? run.updatedAt, observationEnd)];
|
|
101
|
+
const activeExecutionMs = unionDuration(executionIntervals);
|
|
102
|
+
const explicitWaitMs = unionDuration(spans.filter(span => WAIT_KINDS.has(span.kind)).map(span => spanInterval(span, now)));
|
|
103
|
+
const attributedMs = unionDuration(spans.map(span => spanInterval(span, now)));
|
|
104
|
+
const inclusiveByKind = durationsByKind(spans, now);
|
|
105
|
+
const exclusiveByKind = exclusiveDurationsByKind(spans, now);
|
|
106
|
+
const byKind = exclusiveByKind;
|
|
107
|
+
const observedRetryMs = inclusiveByKind.retry ?? 0;
|
|
108
|
+
const reportedRetryDelayMs = spans.filter(span => span.kind === "retry").reduce((sum, span) => sum + numericAttribute(span, "reportedDelayMs"), 0);
|
|
109
|
+
const unattributedMs = Math.max(0, activeExecutionMs - attributedMs);
|
|
110
|
+
const pausedOfflineMs = Math.max(0, calendarAgeMs - activeExecutionMs);
|
|
111
|
+
const topContributors = Object.entries(byKind).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 3).map(([kind, durationMs]) => ({ kind, durationMs }));
|
|
112
|
+
evidence.push({ evidenceId: "timing-active", source: "observability", kind: "active_execution", summary: `Active execution accounted for ${activeExecutionMs} ms`, durationMs: activeExecutionMs });
|
|
113
|
+
for (const [index, contributor] of topContributors.entries())
|
|
114
|
+
evidence.push({ evidenceId: `timing-${index + 1}`, source: "observability", kind: contributor.kind, summary: `${contributor.kind} accounted for ${contributor.durationMs} ms`, durationMs: contributor.durationMs });
|
|
115
|
+
const significantSpans = spans.filter(span => WAIT_KINDS.has(span.kind) || ["retry", "qa_attempt", "qa_fix", "fix"].includes(span.kind)
|
|
116
|
+
|| ["failed", "error", "blocked"].includes(span.outcome ?? ""))
|
|
117
|
+
.sort((left, right) => new Date(right.startedAt).getTime() - new Date(left.startedAt).getTime()).slice(0, 30);
|
|
118
|
+
for (const span of significantSpans)
|
|
119
|
+
evidence.push({ evidenceId: `span-${span.spanId}`, source: "observability", kind: span.kind,
|
|
120
|
+
summary: `${span.name}: ${span.outcome ?? (span.endedAt ? "completed" : "open")}`, observedAt: span.endedAt ?? span.startedAt,
|
|
121
|
+
durationMs: span.durationMs ?? liveDuration(span, now), spanId: span.spanId });
|
|
122
|
+
const longOperations = spans.filter(span => span.kind === "tool" || span.kind === "provider_turn")
|
|
123
|
+
.sort((left, right) => (right.durationMs ?? liveDuration(right, now)) - (left.durationMs ?? liveDuration(left, now))).slice(0, 6);
|
|
124
|
+
for (const span of longOperations)
|
|
125
|
+
evidence.push({ evidenceId: `long-${span.spanId}`, source: "observability", kind: span.kind,
|
|
126
|
+
summary: `${span.endedAt ? "completed" : "open"} ${span.kind}: ${span.name}`, observedAt: span.endedAt ?? span.startedAt,
|
|
127
|
+
durationMs: span.durationMs ?? liveDuration(span, now), spanId: span.spanId });
|
|
128
|
+
sources.process_health = source("process_health", run.status !== "running" ? "not_applicable" : leaseEvidence.live || leaseEvidence.failureObserved ? "available" : "partial", now, leaseEvidence.summary);
|
|
129
|
+
evidence.push({ evidenceId: "lease-health", source: "recovery", kind: "lease", summary: leaseEvidence.summary, observedAt: lease?.heartbeatAt });
|
|
130
|
+
for (const issue of issues.slice(-10))
|
|
131
|
+
evidence.push({ evidenceId: `issue-${evidence.length + 1}`, source: "recovery", kind: issue.code, summary: safeSummary(issue.detail, 500), observedAt: issue.occurred_at });
|
|
132
|
+
for (const operation of operations.filter(item => item.status === "failed" || item.status === "uncertain").slice(-10))
|
|
133
|
+
evidence.push({ evidenceId: `operation-${evidence.length + 1}`, source: "recovery", kind: operation.kind, summary: safeSummary(`${operation.kind}: ${operation.status}${operation.error ? ` (${operation.error.slice(0, 300)})` : ""}`, 500), observedAt: operation.updatedAt });
|
|
134
|
+
const counts = {
|
|
135
|
+
qaAttempts: spans.filter(span => span.kind === "qa_attempt").length,
|
|
136
|
+
qaFailures: spans.filter(span => span.kind === "qa_attempt" && span.outcome && !["passed", "completed"].includes(span.outcome)).length,
|
|
137
|
+
fixes: spans.filter(span => span.kind === "qa_fix" || span.kind === "fix").length,
|
|
138
|
+
retries: spans.filter(span => span.kind === "retry").length + workflowEvents.filter(item => item.type.includes("retry")).length,
|
|
139
|
+
waits: spans.filter(span => WAIT_KINDS.has(span.kind)).length,
|
|
140
|
+
};
|
|
141
|
+
const comparisons = chooseComparison(observable.rollups(), run, ticketSizeBucketForRun(root, run.tickets), now);
|
|
142
|
+
const findings = buildFindings({ run, now, spans, currentState, evidence, topContributors, activeExecutionMs, explicitWaitMs, unattributedMs, byKind, counts, comparisons, leaseEvidence });
|
|
143
|
+
if (findings.every((finding) => finding.code === "largest_contributors"))
|
|
144
|
+
findings.push({ code: "progressing", title: "Run appears to be progressing", summary: `No supported anomaly is present; the longest current phase is ${topContributors[0]?.kind ?? run.checkpoint}.`, confidence: spans.length ? "derived" : "limited", evidenceIds: topContributors[0] ? ["timing-1", "lease-health"] : ["lease-health"] });
|
|
145
|
+
const max = Math.max(1, options.maxDetailSpans ?? 100);
|
|
146
|
+
const sortedDetail = [...spans].sort((a, b) => (b.durationMs ?? liveDuration(b, now)) - (a.durationMs ?? liveDuration(a, now)) || a.spanId.localeCompare(b.spanId));
|
|
147
|
+
const capability = observable.capability(run.runId);
|
|
148
|
+
if (observable.storage().summaryOnly)
|
|
149
|
+
sources.observability = source("observability", "partial", now, "detail hard limit reached; summary-only mode is active");
|
|
150
|
+
const withoutDigest = {
|
|
151
|
+
version: 1, observabilitySchemaVersion: schema === 2 ? 2 : 1, generatedAt: now.toISOString(), runId: run.runId,
|
|
152
|
+
runStatus: workflowRun?.status ?? run.status, legacy: Boolean(workflowRun?.legacy || !observable.available() || (!retainedSpanCount && !executions.length && !storedSummary)),
|
|
153
|
+
capabilities: { version: 1, ...(capability?.rafiVersion ? { rafiVersion: capability.rafiVersion } : {}), sources: { ...capability?.sources, ...sources } },
|
|
154
|
+
currentState, timing: { calendarAgeMs, activeExecutionMs, pausedOfflineMs, explicitWaitMs, attributedMs, unattributedMs, byKind, inclusiveByKind, exclusiveByKind, observedRetryMs, reportedRetryDelayMs, topContributors },
|
|
155
|
+
counts, ...(comparisons ? { comparisons } : {}), findings, evidence,
|
|
156
|
+
detail: { spans: sortedDetail.slice(0, max), omittedSpans: Math.max(0, sortedDetail.length - max) },
|
|
157
|
+
};
|
|
158
|
+
return { ...withoutDigest, digest: diagnosticDigest(withoutDigest) };
|
|
159
|
+
}
|
|
160
|
+
finally {
|
|
161
|
+
observable.close();
|
|
162
|
+
workflow.close();
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
function buildFindings(input) {
|
|
166
|
+
const findings = [];
|
|
167
|
+
if (input.topContributors.length)
|
|
168
|
+
findings.push({ code: "largest_contributors", title: "Largest measured contributors", summary: input.topContributors.map(item => `${item.kind} (${formatDuration(item.durationMs)})`).join(", "), confidence: input.spans.length ? "derived" : "limited", evidenceIds: input.topContributors.map((_, index) => `timing-${index + 1}`) });
|
|
169
|
+
if (input.comparisons && (input.activeExecutionMs > input.comparisons.p90Ms || input.activeExecutionMs >= input.comparisons.medianMs * 1.5))
|
|
170
|
+
findings.push({ code: "duration_abnormal", title: "Duration exceeds comparable runs", summary: `Active execution is ${formatDuration(input.activeExecutionMs)} versus a ${formatDuration(input.comparisons.medianMs)} cohort median and ${formatDuration(input.comparisons.p90Ms)} p90.`, confidence: "derived", evidenceIds: ["timing-active"] });
|
|
171
|
+
const qaMs = (input.byKind.qa_attempt ?? 0) + (input.byKind.qa_fix ?? 0) + (input.byKind.fix ?? 0);
|
|
172
|
+
if (input.counts.qaFailures > 1 || (input.activeExecutionMs > 0 && qaMs / input.activeExecutionMs > 0.3))
|
|
173
|
+
findings.push({ code: "qa_rework", title: "QA rework is a material contributor", summary: `${input.counts.qaFailures} failed review(s), ${input.counts.fixes} fix pass(es), and ${formatDuration(qaMs)} of QA/fix time were observed.`, confidence: "derived", evidenceIds: evidenceForKinds(input.evidence, ["qa_attempt", "qa_fix", "fix"]) });
|
|
174
|
+
const retryMs = input.byKind.retry ?? 0;
|
|
175
|
+
const reportedRetryMs = input.spans.filter(span => span.kind === "retry").reduce((sum, span) => sum + numericAttribute(span, "reportedDelayMs"), 0);
|
|
176
|
+
if (retryMs > 60_000 || reportedRetryMs > 60_000 || (input.activeExecutionMs > 0 && Math.max(retryMs, reportedRetryMs) / input.activeExecutionMs > 0.1))
|
|
177
|
+
findings.push({ code: "retry_overhead", title: "Retry overhead is material", summary: `${formatDuration(retryMs)} observed and ${formatDuration(reportedRetryMs)} provider-reported delay across ${input.counts.retries} retry record(s).`, confidence: "derived", evidenceIds: evidenceForKinds(input.evidence, ["retry"]) });
|
|
178
|
+
if (input.unattributedMs > 60_000 && input.activeExecutionMs > 0 && input.unattributedMs / input.activeExecutionMs > 0.1)
|
|
179
|
+
findings.push({ code: "unattributed_time", title: "Material time is unattributed", summary: `${formatDuration(input.unattributedMs)} of active execution has no correlated span.`, confidence: "limited", evidenceIds: ["timing-active", "lease-health"] });
|
|
180
|
+
if (input.explicitWaitMs > 0)
|
|
181
|
+
findings.push({ code: "explicit_wait", title: "The run includes an explicit wait", summary: `${formatDuration(input.explicitWaitMs)} is attributed to user, dependency, or CI waiting.`, confidence: "observed", evidenceIds: evidenceForKinds(input.evidence, [...WAIT_KINDS]) });
|
|
182
|
+
const latestProgress = input.currentState.map(state => state.lastSemanticProgressAt).filter((value) => Boolean(value)).sort().at(-1);
|
|
183
|
+
const staleMs = latestProgress ? Math.max(0, input.now.getTime() - new Date(latestProgress).getTime()) : 0;
|
|
184
|
+
const activeWait = input.spans.some(span => WAIT_KINDS.has(span.kind) && !span.endedAt);
|
|
185
|
+
if (!activeWait && input.run.status === "running" && staleMs >= 300_000 && input.leaseEvidence.live)
|
|
186
|
+
findings.push({ code: "possibly_stalled", title: "Execution is possibly stalled", summary: `No semantic progress has been observed for ${formatDuration(staleMs)} while the lease/process evidence remains live.`, confidence: "derived", evidenceIds: ["lease-health"] });
|
|
187
|
+
if (input.leaseEvidence.failureObserved)
|
|
188
|
+
findings.push({ code: "lease_process_failure", title: "Lease or process failure observed", summary: input.leaseEvidence.summary, confidence: "observed", evidenceIds: ["lease-health"] });
|
|
189
|
+
return findings;
|
|
190
|
+
}
|
|
191
|
+
function reconcileCurrentState(states, spans, now) {
|
|
192
|
+
const open = new Map();
|
|
193
|
+
for (const span of spans.filter(item => !item.endedAt)) {
|
|
194
|
+
const role = span.role ?? "host";
|
|
195
|
+
const stream = span.stream ?? role;
|
|
196
|
+
const key = `${role}\u0000${stream}`;
|
|
197
|
+
const current = open.get(key);
|
|
198
|
+
if (!current || new Date(span.startedAt).getTime() > new Date(current.startedAt).getTime())
|
|
199
|
+
open.set(key, span);
|
|
200
|
+
}
|
|
201
|
+
const seen = new Set();
|
|
202
|
+
const reconciled = states.map((state) => {
|
|
203
|
+
const key = `${state.role}\u0000${state.stream}`;
|
|
204
|
+
seen.add(key);
|
|
205
|
+
const active = open.get(key);
|
|
206
|
+
const { activeSpanId: _activeSpanId, activeSpanKind: _activeSpanKind, ...base } = state;
|
|
207
|
+
return active ? { ...base, phase: active.name, activeSpanId: active.spanId, activeSpanKind: active.kind } : base;
|
|
208
|
+
});
|
|
209
|
+
for (const [key, span] of open) {
|
|
210
|
+
if (seen.has(key))
|
|
211
|
+
continue;
|
|
212
|
+
reconciled.push({ version: 1, runId: span.runId, role: span.role ?? "host", stream: span.stream ?? span.role ?? "host",
|
|
213
|
+
...(span.executionId ? { executionId: span.executionId } : {}), ...(span.ticketId ? { ticketId: span.ticketId } : {}),
|
|
214
|
+
...(span.deliveryUnitId ? { deliveryUnitId: span.deliveryUnitId } : {}), ...(span.providerSessionId ? { providerSessionId: span.providerSessionId } : {}),
|
|
215
|
+
phase: span.name, activeSpanId: span.spanId, activeSpanKind: span.kind, updatedAt: now.toISOString() });
|
|
216
|
+
}
|
|
217
|
+
return reconciled;
|
|
218
|
+
}
|
|
219
|
+
function chooseComparison(rollups, run, sizeBucket, now) {
|
|
220
|
+
const cutoff = now.getTime() - 90 * 86_400_000;
|
|
221
|
+
const candidates = rollups.filter(item => item.runId !== run.runId && item.status === "completed" && item.completedAt && new Date(item.completedAt).getTime() >= cutoff).slice(0, 50);
|
|
222
|
+
const provider = run.builder?.settings.make;
|
|
223
|
+
const qa = Boolean(run.qa);
|
|
224
|
+
const countBucket = ticketCountBucket(run.tickets.length);
|
|
225
|
+
const levels = [
|
|
226
|
+
{ cohort: { branchMode: run.branchMode, qaEnabled: qa, primaryProvider: provider ?? "unknown", ticketCountBucket: countBucket, ticketSizeBucket: sizeBucket ?? "unknown" }, match: item => item.branchMode === run.branchMode && item.qaEnabled === qa && item.primaryProvider === provider && item.ticketCountBucket === countBucket && item.ticketSizeBucket === sizeBucket },
|
|
227
|
+
{ cohort: { branchMode: run.branchMode, qaEnabled: qa, primaryProvider: provider ?? "unknown" }, match: item => item.branchMode === run.branchMode && item.qaEnabled === qa && item.primaryProvider === provider },
|
|
228
|
+
{ cohort: { branchMode: run.branchMode, qaEnabled: qa }, match: item => item.branchMode === run.branchMode && item.qaEnabled === qa },
|
|
229
|
+
];
|
|
230
|
+
for (const level of levels) {
|
|
231
|
+
const sample = candidates.filter(level.match).map(item => item.activeExecutionMs).filter(value => value >= 0).sort((a, b) => a - b);
|
|
232
|
+
if (sample.length < 5)
|
|
233
|
+
continue;
|
|
234
|
+
return { cohort: level.cohort, sampleSize: sample.length, medianMs: percentile(sample, 0.5), p75Ms: percentile(sample, 0.75), p90Ms: percentile(sample, 0.9) };
|
|
235
|
+
}
|
|
236
|
+
return undefined;
|
|
237
|
+
}
|
|
238
|
+
function durationsByKind(spans, now) {
|
|
239
|
+
const grouped = new Map();
|
|
240
|
+
for (const span of spans)
|
|
241
|
+
grouped.set(span.kind, [...(grouped.get(span.kind) ?? []), spanInterval(span, now)]);
|
|
242
|
+
return Object.fromEntries([...grouped].map(([kind, intervals]) => [kind, unionDuration(intervals)]));
|
|
243
|
+
}
|
|
244
|
+
function exclusiveDurationsByKind(spans, now) {
|
|
245
|
+
const byParent = new Map();
|
|
246
|
+
for (const span of spans)
|
|
247
|
+
if (span.parentSpanId)
|
|
248
|
+
byParent.set(span.parentSpanId, [...(byParent.get(span.parentSpanId) ?? []), span]);
|
|
249
|
+
const grouped = new Map();
|
|
250
|
+
for (const span of spans) {
|
|
251
|
+
const base = spanInterval(span, now);
|
|
252
|
+
const children = (byParent.get(span.spanId) ?? []).map(child => intersect(base, spanInterval(child, now))).filter((value) => Boolean(value));
|
|
253
|
+
const segments = subtractIntervals(base, children);
|
|
254
|
+
grouped.set(span.kind, [...(grouped.get(span.kind) ?? []), ...segments]);
|
|
255
|
+
}
|
|
256
|
+
return Object.fromEntries([...grouped].map(([kind, intervals]) => [kind, unionDuration(intervals)]));
|
|
257
|
+
}
|
|
258
|
+
function subtractIntervals(base, cuts) {
|
|
259
|
+
const merged = mergeIntervals(cuts);
|
|
260
|
+
const result = [];
|
|
261
|
+
let cursor = base[0];
|
|
262
|
+
for (const [start, end] of merged) {
|
|
263
|
+
if (start > cursor)
|
|
264
|
+
result.push([cursor, start]);
|
|
265
|
+
cursor = Math.max(cursor, end);
|
|
266
|
+
}
|
|
267
|
+
if (cursor < base[1])
|
|
268
|
+
result.push([cursor, base[1]]);
|
|
269
|
+
return result;
|
|
270
|
+
}
|
|
271
|
+
function mergeIntervals(intervals) {
|
|
272
|
+
const sorted = intervals.sort((a, b) => a[0] - b[0]);
|
|
273
|
+
const out = [];
|
|
274
|
+
for (const item of sorted) {
|
|
275
|
+
const last = out.at(-1);
|
|
276
|
+
if (!last || item[0] > last[1])
|
|
277
|
+
out.push([...item]);
|
|
278
|
+
else
|
|
279
|
+
last[1] = Math.max(last[1], item[1]);
|
|
280
|
+
}
|
|
281
|
+
return out;
|
|
282
|
+
}
|
|
283
|
+
function intersect(left, right) { const start = Math.max(left[0], right[0]); const end = Math.min(left[1], right[1]); return end >= start ? [start, end] : undefined; }
|
|
284
|
+
function spanInterval(span, now) { return interval(span.startedAt, span.endedAt, now); }
|
|
285
|
+
function interval(start, end, now) { const a = new Date(start).getTime(); const b = end ? new Date(end).getTime() : now.getTime(); return [a, Math.max(a, b)]; }
|
|
286
|
+
export function unionDuration(intervals) {
|
|
287
|
+
const sorted = intervals.filter(([a, b]) => Number.isFinite(a) && Number.isFinite(b) && b >= a).map(([a, b]) => [a, b]).sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
|
288
|
+
let total = 0;
|
|
289
|
+
let start;
|
|
290
|
+
let end = 0;
|
|
291
|
+
for (const [a, b] of sorted) {
|
|
292
|
+
if (start === undefined) {
|
|
293
|
+
start = a;
|
|
294
|
+
end = b;
|
|
295
|
+
}
|
|
296
|
+
else if (a <= end)
|
|
297
|
+
end = Math.max(end, b);
|
|
298
|
+
else {
|
|
299
|
+
total += end - start;
|
|
300
|
+
start = a;
|
|
301
|
+
end = b;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return total + (start === undefined ? 0 : end - start);
|
|
305
|
+
}
|
|
306
|
+
function evaluateLease(lease, now) {
|
|
307
|
+
if (!lease)
|
|
308
|
+
return { summary: "No current project lease exists.", live: false, failureObserved: false };
|
|
309
|
+
const age = Math.max(0, now.getTime() - new Date(lease.heartbeatAt).getTime());
|
|
310
|
+
if (lease.host !== hostname())
|
|
311
|
+
return { summary: `Remote lease heartbeat is ${formatDuration(age)} old; matching remote process identity cannot be verified.`, live: false, failureObserved: false };
|
|
312
|
+
let exists = false;
|
|
313
|
+
try {
|
|
314
|
+
process.kill(lease.pid, 0);
|
|
315
|
+
exists = true;
|
|
316
|
+
}
|
|
317
|
+
catch {
|
|
318
|
+
exists = false;
|
|
319
|
+
}
|
|
320
|
+
let sameStart = false;
|
|
321
|
+
try {
|
|
322
|
+
sameStart = readFileSync(`/proc/${lease.pid}/stat`, "utf8").split(" ")[21] === lease.processStart;
|
|
323
|
+
}
|
|
324
|
+
catch {
|
|
325
|
+
sameStart = false;
|
|
326
|
+
}
|
|
327
|
+
const live = age <= 45_000 && exists && sameStart;
|
|
328
|
+
return { summary: live ? `Lease heartbeat is fresh (${formatDuration(age)} old) and PID/start identity match.` : `Lease health failed: heartbeat age ${formatDuration(age)}, process exists=${exists}, process-start matches=${sameStart}.`, live, failureObserved: !live && (!exists || !sameStart) };
|
|
329
|
+
}
|
|
330
|
+
function collectGit(root, cwd, runner) {
|
|
331
|
+
const evidence = [];
|
|
332
|
+
try {
|
|
333
|
+
const head = runGit(runner, cwd, ["rev-parse", "HEAD"]);
|
|
334
|
+
const branch = runGit(runner, cwd, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
335
|
+
const status = runGit(runner, cwd, ["status", "--porcelain=v1"]);
|
|
336
|
+
const diff = runGit(runner, cwd, ["diff", "--shortstat"]);
|
|
337
|
+
evidence.push({ evidenceId: "git-summary", source: "git", kind: "git", summary: `branch=${branch.stdout.trim() || "unknown"}, head=${head.stdout.trim().slice(0, 12) || "unknown"}, changedPaths=${status.stdout.split("\n").filter(Boolean).length}, diff=${diff.stdout.trim() || "none"}` });
|
|
338
|
+
return { state: [head, branch, status, diff].every(item => item.ok) ? "available" : "partial", evidence };
|
|
339
|
+
}
|
|
340
|
+
catch (error) {
|
|
341
|
+
return { state: "unavailable", detail: String(error).slice(0, 300), evidence };
|
|
342
|
+
}
|
|
343
|
+
finally {
|
|
344
|
+
void root;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
function runGit(runner, cwd, args) {
|
|
348
|
+
if (!ALLOWED_GIT.has(args[0] ?? ""))
|
|
349
|
+
throw new Error("diagnostic git command is not allowlisted");
|
|
350
|
+
return runner("git", args, { cwd, timeoutMs: 5_000 });
|
|
351
|
+
}
|
|
352
|
+
function collectExternal(cwd, runner, now) {
|
|
353
|
+
const remote = runner("git", ["remote", "get-url", "origin"], { cwd, timeoutMs: 5_000 });
|
|
354
|
+
if (remote.timedOut)
|
|
355
|
+
return { state: "timed_out", detail: "Git remote inspection exceeded five seconds", evidence: [] };
|
|
356
|
+
const gitlab = /gitlab/i.test(remote.stdout);
|
|
357
|
+
const command = gitlab ? "glab" : "gh";
|
|
358
|
+
const args = gitlab ? ["mr", "view", "--output", "json"] : ["pr", "view", "--json", "number,state,statusCheckRollup,url"];
|
|
359
|
+
const result = runner(command, args, { cwd, timeoutMs: 5_000 });
|
|
360
|
+
if (result.timedOut)
|
|
361
|
+
return { state: "timed_out", detail: `${gitlab ? "GitLab" : "GitHub"} CLI check exceeded five seconds`, evidence: [] };
|
|
362
|
+
if (!result.ok)
|
|
363
|
+
return { state: "unavailable", detail: safeSummary(result.stderr || `${gitlab ? "GitLab" : "GitHub"} CLI unavailable or unauthenticated`, 300), evidence: [] };
|
|
364
|
+
return { state: "available", evidence: [{ evidenceId: gitlab ? "external-mr" : "external-pr", source: "external", kind: gitlab ? "merge_request" : "pull_request", summary: safeSummary(result.stdout, 2000), observedAt: now.toISOString() }] };
|
|
365
|
+
}
|
|
366
|
+
function defaultCommandRunner(command, args, options) {
|
|
367
|
+
if (command !== "git" && command !== "gh" && command !== "glab")
|
|
368
|
+
return { ok: false, stdout: "", stderr: "command is not allowlisted" };
|
|
369
|
+
try {
|
|
370
|
+
return { ok: true, stdout: execFileSync(command, [...args], { cwd: options.cwd, timeout: options.timeoutMs, encoding: "utf8", maxBuffer: 64 * 1024, stdio: ["ignore", "pipe", "pipe"] }) };
|
|
371
|
+
}
|
|
372
|
+
catch (error) {
|
|
373
|
+
const item = error;
|
|
374
|
+
return { ok: false, stdout: String(item.stdout ?? ""), stderr: String(item.stderr ?? error), timedOut: Boolean(item.killed || item.signal === "SIGTERM") };
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
function readLegacyLifecycle(root, run, limit) {
|
|
378
|
+
const dir = join(root, ".foreman");
|
|
379
|
+
if (!existsSync(dir))
|
|
380
|
+
return { found: false, spans: [] };
|
|
381
|
+
const files = readdirSync(dir).filter(name => name.endsWith(".jsonl")).sort().slice(-5);
|
|
382
|
+
const spans = [];
|
|
383
|
+
for (const file of files) {
|
|
384
|
+
let rows = [];
|
|
385
|
+
try {
|
|
386
|
+
rows = readFileSync(join(dir, file), "utf8").split("\n").filter(Boolean).map(line => JSON.parse(line));
|
|
387
|
+
}
|
|
388
|
+
catch {
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
for (const [index, row] of rows.entries()) {
|
|
392
|
+
const at = typeof row.timestamp === "string" ? row.timestamp : typeof row.at === "string" ? row.at : undefined;
|
|
393
|
+
if (!at || new Date(at).getTime() < new Date(run.createdAt).getTime() || new Date(at).getTime() > new Date(run.updatedAt).getTime() + 60_000)
|
|
394
|
+
continue;
|
|
395
|
+
if (!["step", "branch-complete", "branch-issue", "retry", "escalation"].includes(String(row.event)))
|
|
396
|
+
continue;
|
|
397
|
+
spans.push({ version: 1, spanId: `legacy:${file}:${index}`, runId: run.runId, kind: `legacy_${String(row.event)}`, name: String(row.event), startedAt: at, endedAt: at, durationMs: 0, outcome: typeof row.outcome === "string" ? row.outcome : undefined, completionKnown: false });
|
|
398
|
+
if (spans.length >= limit)
|
|
399
|
+
return { found: true, spans };
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return { found: spans.length > 0, spans };
|
|
403
|
+
}
|
|
404
|
+
function source(name, state, now, detail) { return { source: name, state, observedAt: now.toISOString(), ...(detail ? { detail } : {}) }; }
|
|
405
|
+
function evidenceForKinds(evidence, kinds) { const ids = evidence.filter(item => kinds.includes(item.kind)).map(item => item.evidenceId); return ids.length ? ids : ["timing-1"].filter(id => evidence.some(item => item.evidenceId === id)); }
|
|
406
|
+
function liveDuration(span, now) { return Math.max(0, now.getTime() - new Date(span.startedAt).getTime()); }
|
|
407
|
+
function numericAttribute(span, key) { const value = Number(span.attributes?.[key]); return Number.isFinite(value) && value >= 0 ? value : 0; }
|
|
408
|
+
function percentile(sorted, p) { if (!sorted.length)
|
|
409
|
+
return 0; const index = (sorted.length - 1) * p; const lower = Math.floor(index); const upper = Math.ceil(index); return Math.round(sorted[lower] + (sorted[upper] - sorted[lower]) * (index - lower)); }
|
|
410
|
+
function ticketCountBucket(count) { return count <= 1 ? "1" : count <= 3 ? "2-3" : count <= 7 ? "4-7" : "8+"; }
|
|
411
|
+
export function ticketSizeBucketForRun(projectDir, ticketIds) {
|
|
412
|
+
if (!ticketIds.length || !isTicketsInitialized(projectDir))
|
|
413
|
+
return undefined;
|
|
414
|
+
try {
|
|
415
|
+
const paths = resolveTicketPaths(loadTicketsConfig(projectDir), projectDir);
|
|
416
|
+
const selected = loadTickets(paths.tickets).filter(ticket => ticketIds.includes(ticket.id));
|
|
417
|
+
const order = ["XS", "S", "M", "L", "XL"];
|
|
418
|
+
return selected.length ? selected.map(ticket => ticket.size).sort((a, b) => order.indexOf(b) - order.indexOf(a))[0] : undefined;
|
|
419
|
+
}
|
|
420
|
+
catch {
|
|
421
|
+
return undefined;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
function formatDuration(ms) { if (ms < 1000)
|
|
425
|
+
return `${Math.round(ms)}ms`; if (ms < 60_000)
|
|
426
|
+
return `${(ms / 1000).toFixed(1)}s`; if (ms < 3_600_000)
|
|
427
|
+
return `${(ms / 60_000).toFixed(1)}m`; return `${(ms / 3_600_000).toFixed(1)}h`; }
|
|
428
|
+
function safeSummary(value, maximum) { return String(sanitizeDiagnosticValue(value)).slice(0, maximum); }
|
package/dist/foreman.d.ts
CHANGED
|
@@ -2,8 +2,10 @@ import type { BuilderAdapter, PermissionHandler, PermissionRequest, TurnResult }
|
|
|
2
2
|
import type { PermissionPolicy } from "./permissions/policy.js";
|
|
3
3
|
import { type AnsweredProviderQuestion } from "./providerQuestions.js";
|
|
4
4
|
import type { Log } from "./log.js";
|
|
5
|
-
import { type QaNonconvergenceContext, type QaNonconvergenceDecision } from "./qaReview.js";
|
|
5
|
+
import { type QaNonconvergenceContext, type QaNonconvergenceDecision, type QaReportRecoveryHandler, type QaSessionBoundaryRecovery, type QaSessionHandle } from "./qaReview.js";
|
|
6
6
|
import type { SessionStrategy } from "rafi-spec";
|
|
7
|
+
import type { RunObserver } from "./observability.js";
|
|
8
|
+
import type { QaRecoveryPacket } from "./qaRecovery.js";
|
|
7
9
|
/** Parsed STEP_STATUS marker from a builder's turn. */
|
|
8
10
|
export interface StepStatus {
|
|
9
11
|
kind: "done" | "blocked" | "plan_complete" | "needs_input" | "qa_pass" | "qa_fail" | "unknown";
|
|
@@ -48,15 +50,17 @@ export interface BatchResult {
|
|
|
48
50
|
outcome: "all-done" | "plan-complete" | "blocked" | "needs-human";
|
|
49
51
|
detail?: string;
|
|
50
52
|
}
|
|
53
|
+
export interface ForemanNotificationOptions {
|
|
54
|
+
desktop: boolean;
|
|
55
|
+
terminalBell: boolean;
|
|
56
|
+
}
|
|
51
57
|
/** Drives one builder through a batch of N steps via the STEP_STATUS protocol. */
|
|
52
58
|
export declare class Foreman {
|
|
53
59
|
private builder;
|
|
54
60
|
private readonly log;
|
|
55
|
-
private readonly notificationsEnabled;
|
|
56
61
|
private readonly qaEnabled;
|
|
57
62
|
private readonly qaMaxCycles;
|
|
58
63
|
private readonly projectDir?;
|
|
59
|
-
private readonly qaReviewer?;
|
|
60
64
|
private readonly qaFactory?;
|
|
61
65
|
private readonly qaSessionStrategy;
|
|
62
66
|
private readonly builderFactory?;
|
|
@@ -68,12 +72,23 @@ export declare class Foreman {
|
|
|
68
72
|
private readonly observeQaNativeCompactions?;
|
|
69
73
|
/** Persist a Builder's provider-native event immediately after every turn. */
|
|
70
74
|
private readonly observeBuilderNativeCompactions?;
|
|
75
|
+
private readonly observer?;
|
|
76
|
+
private readonly qaRuntimeContext?;
|
|
77
|
+
private readonly qaContinuityManaged;
|
|
78
|
+
private readonly qaReportRecovery?;
|
|
79
|
+
private qaResumedRecovery?;
|
|
71
80
|
private readonly ticketsEnabled;
|
|
81
|
+
private readonly notificationsEnabled;
|
|
82
|
+
private readonly terminalBellEnabled;
|
|
72
83
|
private readonly qaStream;
|
|
73
84
|
private builderWorkSessions;
|
|
74
|
-
|
|
85
|
+
private readonly fallbackQaRunId;
|
|
86
|
+
constructor(builder: BuilderAdapter, log: Log, notifications?: boolean | ForemanNotificationOptions, qaEnabled?: boolean, qaMaxCycles?: number, projectDir?: string | undefined,
|
|
87
|
+
/** @deprecated QA must be created by qaFactory in a disposable snapshot. */
|
|
88
|
+
_deprecatedSameSessionReviewer?: BuilderAdapter, qaFactory?: ((cwd: string, sessionId?: string) => Promise<BuilderAdapter | QaSessionHandle>) | undefined, qaSessionStrategy?: SessionStrategy, builderFactory?: ((cwd: string, sessionId?: string) => Promise<BuilderAdapter>) | undefined, builderSessionStrategy?: SessionStrategy, qaNonconvergence?: ((context: QaNonconvergenceContext) => Promise<QaNonconvergenceDecision>) | undefined, beforeBuilderTurn?: ((adapter: BuilderAdapter, frozenAction: string) => Promise<BuilderAdapter>) | undefined, builderSessionBoundary?: ((adapter: BuilderAdapter, frozenAction: string, strategy: SessionStrategy) => Promise<BuilderAdapter>) | undefined, qaSessionBoundary?: ((adapter: BuilderAdapter, frozenAction: string, strategy: SessionStrategy, cwd: string, recovery?: QaSessionBoundaryRecovery) => Promise<BuilderAdapter>) | undefined, observeQaNativeCompactions?: ((adapter: BuilderAdapter) => Promise<void>) | undefined,
|
|
75
89
|
/** Persist a Builder's provider-native event immediately after every turn. */
|
|
76
|
-
observeBuilderNativeCompactions?: ((adapter: BuilderAdapter) => Promise<void>) | undefined);
|
|
90
|
+
observeBuilderNativeCompactions?: ((adapter: BuilderAdapter) => Promise<void>) | undefined, observer?: RunObserver | undefined, qaRuntimeContext?: unknown | undefined, qaContinuityManaged?: boolean, qaReportRecovery?: QaReportRecoveryHandler | undefined, qaResumedRecovery?: QaRecoveryPacket | undefined);
|
|
91
|
+
private waitForUserInput;
|
|
77
92
|
/**
|
|
78
93
|
* Send one instruction and resolve any needs_input exchanges before returning.
|
|
79
94
|
* The returned result and status always reflect the final (non-needs_input) turn.
|
|
@@ -90,6 +105,11 @@ export declare class Foreman {
|
|
|
90
105
|
result: TurnResult;
|
|
91
106
|
status: StepStatus;
|
|
92
107
|
}>;
|
|
108
|
+
/** Ask an already-active role session to propose choices for its reported blocker. */
|
|
109
|
+
resolveBlocker(adapter: BuilderAdapter, reason: string, mode?: "builder" | "qa"): Promise<{
|
|
110
|
+
result: TurnResult;
|
|
111
|
+
status: StepStatus;
|
|
112
|
+
}>;
|
|
93
113
|
private observeBuilderNative;
|
|
94
114
|
/**
|
|
95
115
|
* Run a QA review pass on the ticket the builder just completed.
|
|
@@ -97,7 +117,7 @@ export declare class Foreman {
|
|
|
97
117
|
* QA turns are free — they do not advance the step counter.
|
|
98
118
|
*/
|
|
99
119
|
private runQa;
|
|
100
|
-
runQaReview(stepIndex: number): Promise<{
|
|
120
|
+
runQaReview(stepIndex: number, builderResult: string): Promise<{
|
|
101
121
|
outcome: "passed" | "blocked" | "needs-human" | "waived";
|
|
102
122
|
detail?: string;
|
|
103
123
|
summary?: string;
|