immune-brain 4.3.0 → 4.4.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/README.md +37 -0
- package/README.zh-CN.md +37 -0
- package/package.json +1 -1
- package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
- package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +19 -2
- package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +10 -2
- package/plugins/immune-brain/.pi-extension/pi-canary-tool-failure.ts +6 -1
- package/plugins/immune-brain/.pi-extension/runtime-stub.ts +11 -6
- package/plugins/immune-brain/dist/claude/mcp-server.mjs +357 -154
- package/plugins/immune-brain/dist/imm-loop.md +35 -2
- package/plugins/immune-brain/dist/role-prompts/executor.md +6 -0
- package/plugins/immune-brain/runtime/assurance/coordinator.ts +160 -12
- package/plugins/immune-brain/runtime/assurance/qa.ts +57 -42
- package/plugins/immune-brain/runtime/claude/kernel_ports.ts +27 -8
- package/plugins/immune-brain/runtime/claude/mcp_server.ts +9 -2
- package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
- package/plugins/immune-brain/runtime/prompts/executor.md +6 -0
- package/plugins/immune-brain/runtime/unattended/batch_plan.ts +1 -1
- package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +38 -8
- package/plugins/immune-brain/runtime/unattended/batch_reasons.ts +6 -0
- package/plugins/immune-brain/runtime/unattended/batch_runner.ts +84 -146
- package/plugins/immune-brain/runtime/unattended/batch_state.ts +11 -0
|
@@ -116,6 +116,23 @@ batch state transition: the shared `startBatch` driver in
|
|
|
116
116
|
`runtime/unattended/` owns every batch transition, and each child is still
|
|
117
117
|
enrolled, assured, and settled by the Kernel under an ordinary TaskRecord.
|
|
118
118
|
|
|
119
|
+
A successful child Enrollment returns a `running` report with `handoff.role`
|
|
120
|
+
`executor`; the child remains `enrolled`. The Parent implements that TaskIntent
|
|
121
|
+
in the current foreground conversation, stages only its scoped changes, and
|
|
122
|
+
runs focused diagnostics before Kernel Assurance. Re-entering the batch before
|
|
123
|
+
implementation returns the same handoff without QA or another confirmation.
|
|
124
|
+
The runtime owns no model invocation and adds no persisted execution lifecycle.
|
|
125
|
+
|
|
126
|
+
After diagnostics pass, the Parent advances Kernel QA and dispatches any reserved
|
|
127
|
+
foreground Review once using the exact returned `agent_params`, then submits its
|
|
128
|
+
structured verdict. An open Review reservation stays with that Host invocation;
|
|
129
|
+
batch re-entry waits for it rather than redispatching. On Kernel `done`, stage
|
|
130
|
+
only that child's generated terminal audit evidence and call
|
|
131
|
+
`start_unattended_batch` with the same slug. The driver reconciles settlement,
|
|
132
|
+
adopts or creates the one scope-bound commit, then enrolls the next ready child
|
|
133
|
+
under the still-valid authorization. Ordinary Executor handoff and below-limit
|
|
134
|
+
QA repair do not park the batch; genuine budget/authority stops remain fail-closed.
|
|
135
|
+
|
|
119
136
|
Batch execution never pushes a ref, opens or updates a pull request, resolves a
|
|
120
137
|
user decision, or creates, switches, or deletes a Git worktree. Its sole Git
|
|
121
138
|
effect is the batch branch `imm/<initiative-slug>` plus one scope-bounded commit
|
|
@@ -147,8 +164,24 @@ per completed child.
|
|
|
147
164
|
and exactly one same-Host recovery action. Never recommend another Host,
|
|
148
165
|
worktree, Direct Path, unmanaged implementation, or automatic retry.
|
|
149
166
|
- After interruption, read a fresh projection and run only the pending obligation.
|
|
150
|
-
|
|
151
|
-
|
|
167
|
+
`recovery` carries that task/run/record identity, affected acceptance/finding IDs
|
|
168
|
+
and one legal `next_action`; it is an observation, never execution readiness.
|
|
169
|
+
Own-claim technical rework goes to foreground Executor, not user authorization.
|
|
170
|
+
`review_preparation_failed` keeps the batch running/enrolled: repair the Review
|
|
171
|
+
evidence environment and resume `run_review`, retaining fresh QA without batch
|
|
172
|
+
re-confirmation.
|
|
173
|
+
Repair and verify the cause, then dispose each identified finding explicitly by
|
|
174
|
+
`resolve_finding` or evidence-bound `refute_finding`. Local green cannot close
|
|
175
|
+
findings or settle the task; obtain fresh Kernel QA and required Review.
|
|
176
|
+
Environment preparation/resolution/integrity failure is not an assertion finding.
|
|
177
|
+
Use its canonical descriptor reference/digest, phase/outcome, timing and byte
|
|
178
|
+
counts to diagnose; QA diagnostic metadata is capped at 16 KiB and contains no
|
|
179
|
+
stdout/stderr, argv, environment values or arbitrary executor error text.
|
|
180
|
+
A frozen child with `run_qa` pending returns to Parent; batch re-entry does not
|
|
181
|
+
blindly retry an unchanged failure. Fix the environment before a fresh attempt.
|
|
182
|
+
Scope/authority exceptions still use their existing native gates. A committed
|
|
183
|
+
QA result is honored; an interrupted precommit QA run produces no approval.
|
|
184
|
+
Do not rerun fresh QA simply because Review was interrupted.
|
|
152
185
|
- Malformed or stale reviewer output is not a verdict. Keep the existing
|
|
153
186
|
reservation only if the Host reports it valid; use its exact recovery action.
|
|
154
187
|
Do not fabricate a pass or blindly redispatch Review.
|
|
@@ -6,6 +6,12 @@ same-boundary follow-up) in the current Parent conversation. New helpers or
|
|
|
6
6
|
tests inside an approved directory or glob do not require a revision. Keep
|
|
7
7
|
every edit inside the authorized envelope; do not stage unrelated user files. Do not discover or load a Pi Skill.
|
|
8
8
|
|
|
9
|
+
On a batch `handoff.role=executor`, implement only its named enrolled child;
|
|
10
|
+
the batch stays `running` with that child `enrolled`. Return to the Parent after
|
|
11
|
+
scoped implementation, staging and focused diagnostics. Enrollment itself does
|
|
12
|
+
not start QA; the Parent owns the subsequent Kernel Assurance and foreground
|
|
13
|
+
Review handoff under the same still-valid batch authorization.
|
|
14
|
+
|
|
9
15
|
Before handoff, run the permitted diagnostic checks and return commands and
|
|
10
16
|
outcomes to the Parent as structured diagnostic evidence. The read-only Loop
|
|
11
17
|
runtime action only constructs the dispatch envelope; it does not store
|
|
@@ -22,6 +22,7 @@ import type { TaskIntentIdentityToken } from "../kernel/intent_token_registry";
|
|
|
22
22
|
import type { AssuranceHostPort, HostReviewReservation } from "./host_port";
|
|
23
23
|
import type { GithubTrackerResult as GithubTrackerProjectionResult } from "../github_issue_tracker";
|
|
24
24
|
|
|
25
|
+
|
|
25
26
|
export type AssuranceRole = "qa" | "review";
|
|
26
27
|
export interface AssuranceCorrelation {
|
|
27
28
|
record_revision: string;
|
|
@@ -38,7 +39,8 @@ export interface TaskTombstone {
|
|
|
38
39
|
terminal_event_id: string;
|
|
39
40
|
}
|
|
40
41
|
export interface TaskRecordRead {
|
|
41
|
-
|
|
42
|
+
revision?: string;
|
|
43
|
+
record?: { contract?: string; findings: Array<{ kind: string; status: string; id?: string; acceptance_id?: string | null }> } | null;
|
|
42
44
|
}
|
|
43
45
|
export interface TaskIntentRead {
|
|
44
46
|
/**
|
|
@@ -211,12 +213,102 @@ export interface SnapshotDescriptor {
|
|
|
211
213
|
root: string;
|
|
212
214
|
}
|
|
213
215
|
|
|
214
|
-
export
|
|
216
|
+
export type QaPreparationReason = "executable_or_cwd_unavailable" | "process_launch_failed" | "process_cleanup_failed"
|
|
217
|
+
| "protected_input_or_output_drift" | "execution_metadata_limit_exceeded" | "timeout" | "output_limit" | "nonzero_exit"
|
|
218
|
+
| "execution_failed" | "command_identity_changed" | "delivery_cleanup_failed" | "delivery_unavailable";
|
|
219
|
+
export interface QaCheckDiagnostic {
|
|
220
|
+
acceptance_id: string;
|
|
221
|
+
descriptor_ref: string;
|
|
222
|
+
descriptor_digest: string;
|
|
223
|
+
stage: "resolution" | "prepare" | "check" | "integrity";
|
|
224
|
+
outcome: "running" | "passed" | QaPreparationReason;
|
|
225
|
+
elapsed_ms: number;
|
|
226
|
+
exit_code: number | null;
|
|
227
|
+
stdout_bytes: number | null;
|
|
228
|
+
stderr_bytes: number | null;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const QA_STAGES = ["resolution", "prepare", "check", "integrity"];
|
|
232
|
+
const QA_OUTCOMES = ["running", "passed", "executable_or_cwd_unavailable", "process_launch_failed", "process_cleanup_failed",
|
|
233
|
+
"protected_input_or_output_drift", "execution_metadata_limit_exceeded", "timeout", "output_limit", "nonzero_exit", "execution_failed",
|
|
234
|
+
"command_identity_changed", "delivery_cleanup_failed", "delivery_unavailable"];
|
|
235
|
+
/** The Tool boundary never spreads arbitrary executor fields. */
|
|
236
|
+
export function qaDiagnosticMetadata(d: QaCheckDiagnostic): QaCheckDiagnostic {
|
|
237
|
+
if (typeof d.acceptance_id !== "string" || !d.acceptance_id
|
|
238
|
+
|| !/^acceptance\/\d{1,6}\/verification\/(command|environment\/prepare)$/.test(d.descriptor_ref)
|
|
239
|
+
|| !/^sha256:[a-f0-9]{64}$/.test(d.descriptor_digest) || !QA_STAGES.includes(d.stage) || !QA_OUTCOMES.includes(d.outcome)
|
|
240
|
+
|| !Number.isFinite(d.elapsed_ms) || d.elapsed_ms < 0
|
|
241
|
+
|| [d.stdout_bytes, d.stderr_bytes].some(n => n !== null && (!Number.isSafeInteger(n) || n < 0))
|
|
242
|
+
|| (d.exit_code !== null && !Number.isSafeInteger(d.exit_code))) throw new Error("invalid QA diagnostic metadata");
|
|
243
|
+
const safe = { acceptance_id: d.acceptance_id, descriptor_ref: d.descriptor_ref, descriptor_digest: d.descriptor_digest,
|
|
244
|
+
stage: d.stage, outcome: d.outcome, elapsed_ms: d.elapsed_ms, exit_code: d.exit_code, stdout_bytes: d.stdout_bytes, stderr_bytes: d.stderr_bytes };
|
|
245
|
+
if (Buffer.byteLength(JSON.stringify(safe)) > 16_384) throw new QaPreparationError("prepare", [], "execution_metadata_limit_exceeded");
|
|
246
|
+
return safe;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export interface QaVerificationProgressInput {
|
|
215
250
|
index: number;
|
|
216
251
|
total: number;
|
|
217
252
|
acceptance_id: string;
|
|
218
253
|
phase: "running" | "passed" | "failed";
|
|
219
254
|
elapsed_ms: number;
|
|
255
|
+
diagnostic?: QaCheckDiagnostic;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export class QaPreparationError extends Error {
|
|
259
|
+
constructor(public readonly stage: QaCheckDiagnostic["stage"], public readonly acceptance_ids: string[], public readonly reason: QaPreparationReason,
|
|
260
|
+
public readonly diagnostics: QaCheckDiagnostic[] = []) {
|
|
261
|
+
if (!QA_STAGES.includes(stage) || !QA_OUTCOMES.includes(reason)) throw new Error("invalid QA preparation classification");
|
|
262
|
+
const safe = diagnostics.map(qaDiagnosticMetadata);
|
|
263
|
+
if (Buffer.byteLength(JSON.stringify(safe)) > 16_384) throw new QaPreparationError("prepare", [], "execution_metadata_limit_exceeded");
|
|
264
|
+
super(`QA ${stage} failed (${reason}); affected checks=${acceptance_ids.length}`);
|
|
265
|
+
this.diagnostics = safe;
|
|
266
|
+
this.name = "QaPreparationError";
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export type QaVerificationProgress = QaVerificationProgressInput;
|
|
271
|
+
|
|
272
|
+
export interface AssuranceRecovery {
|
|
273
|
+
category: "repair" | "environment" | "authorization";
|
|
274
|
+
task_id: string;
|
|
275
|
+
run_id: string | null;
|
|
276
|
+
record_revision: string;
|
|
277
|
+
next_obligation: AssuranceProjectionResult["projection"]["next_obligation"];
|
|
278
|
+
acceptance_ids: string[];
|
|
279
|
+
finding_ids: string[];
|
|
280
|
+
next_action: string;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Observation only; fresh Kernel facts remain the sole owner of the obligation. */
|
|
284
|
+
export function deriveAssuranceRecovery(taskId: string, fresh: AssuranceProjectionResult,
|
|
285
|
+
result: { state?: string; environment_failure?: boolean; diagnostics?: QaCheckDiagnostic[]; reason?: string },
|
|
286
|
+
findings: NonNullable<TaskRecordRead["record"]>["findings"] = []): AssuranceRecovery | null {
|
|
287
|
+
if (fresh.error || fresh.claim?.task_id !== taskId || fresh.projection.lifecycle !== "active") return null;
|
|
288
|
+
const p = fresh.projection;
|
|
289
|
+
const findingIds = [...p.blocking_finding_ids, ...p.unresolved_user_decision_ids, ...p.replan_required_ids];
|
|
290
|
+
const scope = result.reason?.includes("task delivery contains paths outside the authorization envelope");
|
|
291
|
+
const migration = result.reason?.includes("verification_contract_migration_required");
|
|
292
|
+
const unstaged = result.reason?.includes("task delivery has unstaged or untracked changes");
|
|
293
|
+
const reviewPreparation = result.state === "review_preparation_failed" && p.next_obligation === "run_review";
|
|
294
|
+
const authority = scope || migration || p.next_obligation === "resolve_user_decision" || p.next_obligation === "revise_intent"
|
|
295
|
+
|| p.unresolved_user_decision_ids.length > 0 || p.replan_required_ids.length > 0 || p.authorization.state !== "none";
|
|
296
|
+
const repair = unstaged || p.next_obligation === "resolve_findings" || p.blocking_finding_ids.length > 0;
|
|
297
|
+
if (!authority && !repair && !result.environment_failure && !reviewPreparation) return null;
|
|
298
|
+
const affected = findings.filter(f => f.status === "open" && f.id && findingIds.includes(f.id) && f.acceptance_id).map(f => f.acceptance_id!);
|
|
299
|
+
const category = authority ? "authorization" : repair ? "repair" : "environment";
|
|
300
|
+
return { category, task_id: taskId, run_id: p.run_id ?? null, record_revision: p.record_revision,
|
|
301
|
+
next_obligation: p.next_obligation, finding_ids: findingIds,
|
|
302
|
+
acceptance_ids: [...new Set(affected.length ? affected : result.diagnostics?.length ? result.diagnostics.map(d => d.acceptance_id) : reviewPreparation ? p.fresh_acceptance_ids : p.missing_acceptance_ids)],
|
|
303
|
+
next_action: scope ? "Reconcile the listed paths with the TaskIntent scope: unstage unrelated changes or submit the required native Intent revision."
|
|
304
|
+
: migration ? "route Planner for the verification descriptor v2 Intent revision and native gate"
|
|
305
|
+
: category === "authorization"
|
|
306
|
+
? p.authorization.blocked ? "inspect authority state" : p.next_obligation === "revise_intent" ? "route Planner for the required Intent revision and native gate" : "request_authorization"
|
|
307
|
+
: unstaged ? "Stage only the scoped implementation changes, then call advance_assurance."
|
|
308
|
+
: category === "repair" ? "Route foreground Executor to repair and verify the identified findings; dispose each by resolve_finding or evidence-bound refute_finding, then run fresh advance_assurance."
|
|
309
|
+
: reviewPreparation ? "Repair the Review evidence environment, then call advance_assurance to resume run_review; retain fresh QA and do not re-confirm the batch."
|
|
310
|
+
: "Repair the identified verification environment, then call advance_assurance; a local check is non-attesting.",
|
|
311
|
+
};
|
|
220
312
|
}
|
|
221
313
|
|
|
222
314
|
export interface ForegroundToolUpdate {
|
|
@@ -224,7 +316,13 @@ export interface ForegroundToolUpdate {
|
|
|
224
316
|
details: Record<string, unknown>;
|
|
225
317
|
}
|
|
226
318
|
|
|
227
|
-
|
|
319
|
+
type AssuranceRecoveryFields = {
|
|
320
|
+
diagnostics?: QaCheckDiagnostic[];
|
|
321
|
+
environment_failure?: boolean;
|
|
322
|
+
recovery?: AssuranceRecovery;
|
|
323
|
+
recovery_error?: "fresh_kernel_projection_unavailable";
|
|
324
|
+
};
|
|
325
|
+
export type AssuranceAdvanceResult = AssuranceRecoveryFields & (
|
|
228
326
|
| { state: "review_ready"; operation: "review"; operation_id: string; snapshot_digest: string; review_bundle_digest: string; agent_params: unknown }
|
|
229
327
|
| { state: "rework"; operation: "qa"; operation_id: string; summary: string }
|
|
230
328
|
| { state: "cancelled"; operation: "qa" | "review"; operation_id: string; reason: string }
|
|
@@ -238,14 +336,14 @@ export type AssuranceAdvanceResult =
|
|
|
238
336
|
| { state: "settlement_unknown"; operation: "qa" | "review"; operation_id: string; reason: string }
|
|
239
337
|
| { state: "blocked"; reason: string; code?: "verdict_invalid" }
|
|
240
338
|
| { state: "completed" }
|
|
241
|
-
| { state: "stopped" };
|
|
339
|
+
| { state: "stopped" });
|
|
242
340
|
|
|
243
|
-
export type AssuranceSubmitReviewResult =
|
|
341
|
+
export type AssuranceSubmitReviewResult = AssuranceRecoveryFields & (
|
|
244
342
|
| { state: "rework"; operation: "review"; operation_id: string; summary: string }
|
|
245
343
|
| { state: "review_preparation_failed"; operation: "review"; operation_id: string; reason: string }
|
|
246
344
|
| { state: "completed" }
|
|
247
345
|
| { state: "settlement_unknown"; operation: "qa" | "review"; operation_id: string; reason: string }
|
|
248
|
-
| { state: "blocked"; reason: string; code?: "verdict_invalid" };
|
|
346
|
+
| { state: "blocked"; reason: string; code?: "verdict_invalid" });
|
|
249
347
|
|
|
250
348
|
export type ActiveAssuranceState =
|
|
251
349
|
| { state: "running"; operation: "qa"; operation_id: string; deadline_seconds: number }
|
|
@@ -663,6 +761,36 @@ export class AssuranceCoordinator {
|
|
|
663
761
|
sessionGenerationValue(): number { return this.sessionGeneration; }
|
|
664
762
|
|
|
665
763
|
async advance(taskId: string, ctx: HostContext, signal?: AbortSignal, onUpdate?: (update: ForegroundToolUpdate) => void): Promise<AssuranceAdvanceResult> {
|
|
764
|
+
const checks = new Map<string, QaCheckDiagnostic>();
|
|
765
|
+
const result = await this.advanceOnce(taskId, ctx, signal, update => {
|
|
766
|
+
const diagnostic = update.details.diagnostic as QaCheckDiagnostic | undefined;
|
|
767
|
+
if (diagnostic) {
|
|
768
|
+
checks.set(diagnostic.descriptor_ref, diagnostic);
|
|
769
|
+
if (Buffer.byteLength(JSON.stringify([...checks.values()])) > 16_384)
|
|
770
|
+
throw new QaPreparationError("prepare", [diagnostic.acceptance_id], "execution_metadata_limit_exceeded",
|
|
771
|
+
[{ ...diagnostic, stage: "prepare", outcome: "execution_metadata_limit_exceeded" }]);
|
|
772
|
+
}
|
|
773
|
+
onUpdate?.(update);
|
|
774
|
+
});
|
|
775
|
+
const diagnostics = result.diagnostics ?? [...checks.values()];
|
|
776
|
+
return this.withRecovery(taskId, ctx, diagnostics.length ? { ...result, diagnostics } : result);
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
private async withRecovery<T extends AssuranceAdvanceResult | AssuranceSubmitReviewResult>(taskId: string, ctx: HostContext, enriched: T): Promise<T> {
|
|
780
|
+
if (enriched.recovery_error || !["failed", "blocked", "rework", "review_preparation_failed"].includes(enriched.state)) return enriched;
|
|
781
|
+
try {
|
|
782
|
+
const fresh = await this.ports.projectTask(ctx.cwd, taskId);
|
|
783
|
+
if (fresh.error || fresh.claim?.task_id !== taskId || fresh.projection.lifecycle !== "active")
|
|
784
|
+
return { ...enriched, recovery_error: "fresh_kernel_projection_unavailable" };
|
|
785
|
+
const record = await this.ports.readTaskRecord(ctx.cwd, taskId);
|
|
786
|
+
if (!record.record || record.revision !== fresh.projection.record_revision)
|
|
787
|
+
return { ...enriched, recovery_error: "fresh_kernel_projection_unavailable" };
|
|
788
|
+
const recovery = deriveAssuranceRecovery(taskId, fresh, enriched, record.record?.findings);
|
|
789
|
+
return recovery ? { ...enriched, recovery } : enriched;
|
|
790
|
+
} catch { return { ...enriched, recovery_error: "fresh_kernel_projection_unavailable" }; }
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
private async advanceOnce(taskId: string, ctx: HostContext, signal?: AbortSignal, onUpdate?: (update: ForegroundToolUpdate) => void): Promise<AssuranceAdvanceResult> {
|
|
666
794
|
if (this.isInvocationOpen(taskId)) return { state: "blocked", reason: "an authority invocation is already open" };
|
|
667
795
|
const active = this.active(taskId);
|
|
668
796
|
if (active?.state === "review_ready") {
|
|
@@ -671,12 +799,14 @@ export class AssuranceCoordinator {
|
|
|
671
799
|
try {
|
|
672
800
|
projection = await this.ports.projectTask(ctx.cwd, taskId);
|
|
673
801
|
} catch (error) {
|
|
674
|
-
return { state: "blocked", reason: `cannot validate Review reservation: ${boundedAssuranceError(error)}
|
|
802
|
+
return { state: "blocked", reason: `cannot validate Review reservation: ${boundedAssuranceError(error)}`, recovery_error: "fresh_kernel_projection_unavailable" };
|
|
675
803
|
}
|
|
676
804
|
const matches = reservationStillValid(reservation, projection, taskId);
|
|
677
805
|
if (matches) return this.reviewReadyResult(taskId);
|
|
678
806
|
if (reservation) this.releaseReviewReservation(taskId, reservation);
|
|
679
807
|
this.rejectedReviewOperations.delete(taskId);
|
|
808
|
+
if (projection.error || (projection.projection.lifecycle !== "done" && projection.projection.lifecycle !== "stopped" && (!projection.claim || projection.claim.task_id !== taskId)))
|
|
809
|
+
return { state: "blocked", reason: projection.error ?? "no active backend claim for this task", recovery_error: "fresh_kernel_projection_unavailable" };
|
|
680
810
|
}
|
|
681
811
|
const refreshed = this.active(taskId);
|
|
682
812
|
if (refreshed?.state === "running") return { state: "blocked", reason: `assurance operation ${refreshed.operation_id} is already running` };
|
|
@@ -698,6 +828,7 @@ export class AssuranceCoordinator {
|
|
|
698
828
|
// nothing instead of being reported as an unknown settlement.
|
|
699
829
|
let boundaryBaseline: string | null = null;
|
|
700
830
|
let reviewPreparationStarted = false;
|
|
831
|
+
let initialProjectionAvailable = false;
|
|
701
832
|
// Set only by the QA job-budget deadline, which is not a host cancellation.
|
|
702
833
|
let qaJobBudgetExceeded = false;
|
|
703
834
|
let phase = "preparing";
|
|
@@ -738,13 +869,14 @@ export class AssuranceCoordinator {
|
|
|
738
869
|
projection = await this.ports.projectTask(ctx.cwd, taskId);
|
|
739
870
|
}
|
|
740
871
|
ensureOperationLive();
|
|
741
|
-
if (projection.error) return { state: "blocked", reason: projection.error };
|
|
872
|
+
if (projection.error) return { state: "blocked", reason: projection.error, recovery_error: "fresh_kernel_projection_unavailable" };
|
|
742
873
|
if (projection.projection.lifecycle === "done" || projection.projection.lifecycle === "stopped") {
|
|
743
874
|
this.unknownOperations.delete(taskId);
|
|
744
875
|
return { state: projection.projection.lifecycle === "done" ? "completed" : "stopped" };
|
|
745
876
|
}
|
|
746
|
-
if (!projection.claim) return { state: "blocked", reason: "no active backend claim" };
|
|
747
|
-
if (projection.claim.task_id !== taskId) return { state: "blocked", reason: `backend claim belongs to ${projection.claim.task_id}, not ${taskId}
|
|
877
|
+
if (!projection.claim) return { state: "blocked", reason: "no active backend claim", recovery_error: "fresh_kernel_projection_unavailable" };
|
|
878
|
+
if (projection.claim.task_id !== taskId) return { state: "blocked", reason: `backend claim belongs to ${projection.claim.task_id}, not ${taskId}`, recovery_error: "fresh_kernel_projection_unavailable" };
|
|
879
|
+
initialProjectionAvailable = true;
|
|
748
880
|
// A closed Kernel projection, not the lost host reply, decides what
|
|
749
881
|
// remains. Committed QA/Review is never replayed from local memory.
|
|
750
882
|
if (projection.projection.lifecycle === "active") this.unknownOperations.delete(taskId);
|
|
@@ -822,6 +954,7 @@ export class AssuranceCoordinator {
|
|
|
822
954
|
operationController.abort(new Error(`deterministic QA exceeded its declared ${Math.ceil(qaJobTimeoutMs / 60_000)} minute job budget`));
|
|
823
955
|
}, qaJobTimeoutMs);
|
|
824
956
|
try {
|
|
957
|
+
phase = "verifying";
|
|
825
958
|
qaVerdict = await this.ports.runQa(assurance.snapshot, assurance.descriptors, {
|
|
826
959
|
signal: operationController.signal,
|
|
827
960
|
onProgress: (item) => progress("verifying", `QA ${item.index}/${item.total} ${item.acceptance_id} ${item.phase}`, {
|
|
@@ -830,6 +963,7 @@ export class AssuranceCoordinator {
|
|
|
830
963
|
acceptance_id: item.acceptance_id,
|
|
831
964
|
acceptance_phase: item.phase,
|
|
832
965
|
elapsed_ms: item.elapsed_ms,
|
|
966
|
+
...(item.diagnostic ? { diagnostic: qaDiagnosticMetadata(item.diagnostic) } : {}),
|
|
833
967
|
}),
|
|
834
968
|
});
|
|
835
969
|
} finally {
|
|
@@ -950,6 +1084,16 @@ export class AssuranceCoordinator {
|
|
|
950
1084
|
progress("review_ready", "QA passed; invoke the reserved foreground Agent, then call submit_review", { snapshot_digest: snapshotDigest(review.snapshot), review_bundle_digest: review.snapshot.review_bundle_digest ?? "", agent_params: hostReservation.dispatch });
|
|
951
1085
|
return { state: "review_ready", operation: "review", operation_id: operationId, snapshot_digest: snapshotDigest(review.snapshot), review_bundle_digest: review.snapshot.review_bundle_digest ?? "", agent_params: hostReservation.dispatch };
|
|
952
1086
|
} catch (error) {
|
|
1087
|
+
// Recovery decoration must not bypass the initial read retry policy.
|
|
1088
|
+
if (!initialProjectionAvailable && !aborted() && !(error instanceof VerificationAbortedError))
|
|
1089
|
+
return { state: "failed", operation: "qa", operation_id: operationId, reason: `${phase}: ${boundedAssuranceError(error)}`,
|
|
1090
|
+
recovery_error: "fresh_kernel_projection_unavailable" };
|
|
1091
|
+
if (error instanceof QaPreparationError && !authorityCommitted && !authorityBoundaryStarted && !aborted())
|
|
1092
|
+
return { state: "failed", operation: "qa", operation_id: operationId, reason: error.message,
|
|
1093
|
+
environment_failure: true, diagnostics: error.diagnostics.map(qaDiagnosticMetadata) };
|
|
1094
|
+
if (phase === "verifying" && !authorityCommitted && !authorityBoundaryStarted && !aborted() && !(error instanceof VerificationAbortedError))
|
|
1095
|
+
return { state: "failed", operation: "qa", operation_id: operationId,
|
|
1096
|
+
reason: "QA execution failed before attestation (execution_failed)", environment_failure: true };
|
|
953
1097
|
if (reviewPreparationStarted) {
|
|
954
1098
|
const reason = aborted() || error instanceof VerificationAbortedError
|
|
955
1099
|
? `${phase}: host cancellation`
|
|
@@ -980,6 +1124,10 @@ export class AssuranceCoordinator {
|
|
|
980
1124
|
}
|
|
981
1125
|
|
|
982
1126
|
async submitReview(taskId: string, ctx: HostContext, verdictInput: unknown): Promise<AssuranceSubmitReviewResult> {
|
|
1127
|
+
return this.withRecovery(taskId, ctx, await this.submitReviewOnce(taskId, ctx, verdictInput));
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
private async submitReviewOnce(taskId: string, ctx: HostContext, verdictInput: unknown): Promise<AssuranceSubmitReviewResult> {
|
|
983
1131
|
const unknown = this.unknownOperations.get(taskId);
|
|
984
1132
|
if (unknown) return { state: "settlement_unknown", operation: unknown.operation, operation_id: unknown.operationId, reason: unknown.reason };
|
|
985
1133
|
const rejected = this.rejectedReviewOperations.get(taskId);
|
|
@@ -992,12 +1140,12 @@ export class AssuranceCoordinator {
|
|
|
992
1140
|
} catch (error) {
|
|
993
1141
|
const reason = boundedAssuranceError(error);
|
|
994
1142
|
this.releaseReviewReservation(taskId, reservation, reason);
|
|
995
|
-
return { state: "blocked", reason };
|
|
1143
|
+
return { state: "blocked", reason, recovery_error: "fresh_kernel_projection_unavailable" };
|
|
996
1144
|
}
|
|
997
1145
|
if (fresh.error || !fresh.claim || fresh.claim.task_id !== taskId || compareReservationSnapshot(reservation.snapshot, fresh.projection).length > 0 || fresh.projection.lifecycle !== reservation.snapshot.lifecycle || fresh.projection.artifact_state !== reservation.snapshot.artifact_state) {
|
|
998
1146
|
const reason = fresh.error ?? "assurance snapshot changed before Review submission";
|
|
999
1147
|
this.releaseReviewReservation(taskId, reservation, reason);
|
|
1000
|
-
return { state: "blocked", reason };
|
|
1148
|
+
return { state: "blocked", reason, ...(fresh.error || !fresh.claim || fresh.claim.task_id !== taskId ? { recovery_error: "fresh_kernel_projection_unavailable" as const } : {}) };
|
|
1001
1149
|
}
|
|
1002
1150
|
if (reservation.snapshot.review_revision) {
|
|
1003
1151
|
try {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { snapshotDigest, type SnapshotDescriptor, type
|
|
2
|
+
import { snapshotDigest, QaPreparationError, type AssuranceVerdict, type SnapshotDescriptor, type QaCheckDiagnostic, type QaPreparationReason, type QaVerificationProgressInput } from "./coordinator";
|
|
3
|
+
export { QaPreparationError, type QaCheckDiagnostic, type QaPreparationReason, type QaVerificationProgressInput } from "./coordinator";
|
|
3
4
|
import { assertDeliveryClean, materializeDeliveryWorkspace, removeTaskOwnedTree, writeDeliveryTree } from "./delivery_workspace";
|
|
4
5
|
import { runFixedVerification, resolveVerificationCommand, assertCommandIdentity, verificationPath, VerificationAbortedError, VerificationCleanupError, VerificationLaunchError, type FrozenCommand, type VerificationDescriptor, type VerificationCommand } from "./verification";
|
|
5
6
|
import { mkdtempSync, realpathSync } from "node:fs";
|
|
@@ -9,14 +10,6 @@ import { qaFindingId } from "./qa_findings";
|
|
|
9
10
|
import { captureGitTaskRevisionSnapshot } from "../workspace_scope";
|
|
10
11
|
import { readTaskRecord } from "../kernel/storage";
|
|
11
12
|
|
|
12
|
-
export interface QaVerificationProgressInput {
|
|
13
|
-
index: number;
|
|
14
|
-
total: number;
|
|
15
|
-
acceptance_id: string;
|
|
16
|
-
phase: "running" | "passed" | "failed";
|
|
17
|
-
elapsed_ms: number;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
13
|
function deliveryTreeForSnapshot(
|
|
21
14
|
snapshot: SnapshotDescriptor,
|
|
22
15
|
writeTree: typeof writeDeliveryTree,
|
|
@@ -37,13 +30,6 @@ function deliveryTreeForSnapshot(
|
|
|
37
30
|
return writeDeliveryTree(snapshot.root, captured);
|
|
38
31
|
}
|
|
39
32
|
|
|
40
|
-
export class QaPreparationError extends Error {
|
|
41
|
-
constructor(public readonly stage: "resolution" | "prepare" | "integrity", public readonly acceptance_ids: string[], public readonly reason: string) {
|
|
42
|
-
super(`QA ${stage} failed (${reason}); affected checks=${acceptance_ids.join(",")}`);
|
|
43
|
-
this.name = "QaPreparationError";
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
|
|
47
33
|
export async function runDeterministicQa(
|
|
48
34
|
snapshot: SnapshotDescriptor,
|
|
49
35
|
descriptors: Map<string, VerificationDescriptor>,
|
|
@@ -74,38 +60,60 @@ export async function runDeterministicQa(
|
|
|
74
60
|
const path = verificationPath();
|
|
75
61
|
const findings: NonNullable<AssuranceVerdict["findings"]> = [];
|
|
76
62
|
const evidence: unknown[] = [];
|
|
77
|
-
const allCommands: FrozenCommand[] = [];
|
|
63
|
+
const allCommands: Array<{ frozen: FrozenCommand; diagnostics: QaCheckDiagnostic[] }> = [];
|
|
78
64
|
for (const [environmentKey, group] of groups) {
|
|
79
65
|
live();
|
|
80
66
|
const ids = group.map(item => item.id);
|
|
81
67
|
const environment = group[0]!.descriptor.environment;
|
|
82
|
-
const
|
|
83
|
-
|
|
68
|
+
const diagnostics = (stage: QaCheckDiagnostic["stage"], outcome: QaCheckDiagnostic["outcome"], affected = group,
|
|
69
|
+
commandStage: "prepare" | "check" = "check", elapsed = 0, result?: { exit_code: number; stdout: string; stderr: string }): QaCheckDiagnostic[] =>
|
|
70
|
+
affected.map(item => ({ acceptance_id: item.id,
|
|
71
|
+
descriptor_ref: `acceptance/${item.index}/verification/${commandStage === "prepare" ? "environment/prepare" : "command"}`,
|
|
72
|
+
descriptor_digest: `sha256:${createHash("sha256").update(JSON.stringify(item.descriptor)).digest("hex")}`,
|
|
73
|
+
stage, outcome, elapsed_ms: elapsed, exit_code: result?.exit_code ?? null,
|
|
74
|
+
stdout_bytes: result ? Buffer.byteLength(result.stdout) : null, stderr_bytes: result ? Buffer.byteLength(result.stderr) : null }));
|
|
75
|
+
const { delivery, home, deliveryPrefix } = (() => {
|
|
76
|
+
let delivery: ReturnType<typeof materializeDeliveryWorkspace> | undefined, home: string | undefined;
|
|
77
|
+
try {
|
|
78
|
+
delivery = (options._materializeDeliveryWorkspace ?? materializeDeliveryWorkspace)(snapshot.root, tree);
|
|
79
|
+
home = mkdtempSync(join(tmpdir(), "imm-qa-home-"));
|
|
80
|
+
return { delivery, home, deliveryPrefix: `${realpathSync(delivery.root)}${sep}` };
|
|
81
|
+
} catch {
|
|
82
|
+
let reason: QaPreparationReason = "delivery_unavailable";
|
|
83
|
+
try { try { delivery?.cleanup(); } finally { if (home) removeTaskOwnedTree(home); } }
|
|
84
|
+
catch { reason = "delivery_cleanup_failed"; }
|
|
85
|
+
throw new QaPreparationError("prepare", ids, reason, diagnostics("prepare", reason));
|
|
86
|
+
}
|
|
87
|
+
})();
|
|
84
88
|
const clean = () => {
|
|
85
89
|
if (options._materializeDeliveryWorkspace) return;
|
|
86
90
|
try { assertDeliveryClean(delivery.root, tree, delivery.seal, environment.writable_paths); }
|
|
87
|
-
catch { throw new QaPreparationError("integrity", ids, "protected_input_or_output_drift"); }
|
|
91
|
+
catch { throw new QaPreparationError("integrity", ids, "protected_input_or_output_drift", diagnostics("integrity", "protected_input_or_output_drift")); }
|
|
88
92
|
};
|
|
89
93
|
const groupCommands: FrozenCommand[] = [];
|
|
90
|
-
const
|
|
91
|
-
|
|
94
|
+
const execute = async (command: VerificationCommand, stage: "prepare" | "check", affected = group) => {
|
|
95
|
+
const started = performance.now();
|
|
96
|
+
const fail = (errorStage: QaCheckDiagnostic["stage"], reason: QaPreparationReason) =>
|
|
97
|
+
new QaPreparationError(errorStage, affected.map(item => item.id), reason, diagnostics(errorStage, reason, affected, stage, Math.round(performance.now() - started)));
|
|
92
98
|
live(); clean();
|
|
93
99
|
let frozen: FrozenCommand;
|
|
94
100
|
try { frozen = (options._resolveVerificationCommand ?? resolveVerificationCommand)(delivery.root, command, path); }
|
|
95
|
-
catch { throw
|
|
101
|
+
catch { throw fail("resolution", "executable_or_cwd_unavailable"); }
|
|
96
102
|
let result;
|
|
97
103
|
try { result = await (options._runFixedVerification ?? runFixedVerification)(delivery.root, command, frozen, { signal: options.signal, home, path }); }
|
|
98
104
|
catch (error) {
|
|
99
|
-
if (error instanceof
|
|
100
|
-
if (error instanceof
|
|
101
|
-
throw
|
|
105
|
+
if (error instanceof VerificationAbortedError) throw error;
|
|
106
|
+
if (error instanceof VerificationLaunchError) throw fail("resolution", "process_launch_failed");
|
|
107
|
+
if (error instanceof VerificationCleanupError) throw fail("resolution", "process_cleanup_failed");
|
|
108
|
+
throw fail(stage, "execution_failed");
|
|
102
109
|
}
|
|
103
110
|
live(); clean();
|
|
104
111
|
groupCommands.push(frozen);
|
|
105
112
|
if (frozen?.entry) {
|
|
106
|
-
|
|
113
|
+
const identityDiagnostics = diagnostics("integrity", "command_identity_changed", affected, stage, Math.round(performance.now() - started), result);
|
|
114
|
+
if (!command.executable.startsWith("./")) allCommands.push({ frozen, diagnostics: identityDiagnostics });
|
|
107
115
|
else if (frozen.interpreter && !frozen.interpreter.path.startsWith(deliveryPrefix))
|
|
108
|
-
allCommands.push({ entry: frozen.interpreter, interpreter: null, interpreter_args: [] });
|
|
116
|
+
allCommands.push({ frozen: { entry: frozen.interpreter, interpreter: null, interpreter_args: [] }, diagnostics: identityDiagnostics });
|
|
109
117
|
}
|
|
110
118
|
// Paths and process output are not persisted. Identity hashes describe
|
|
111
119
|
// observed entries, not a claim about every transitive dependency.
|
|
@@ -114,38 +122,45 @@ export async function runDeterministicQa(
|
|
|
114
122
|
entry: frozen?.entry?.content_hash ?? null, interpreter: frozen?.interpreter?.content_hash ?? null,
|
|
115
123
|
exit_code: result.exit_code, timed_out: result.timed_out, output_limited: result.output_limited });
|
|
116
124
|
if (Buffer.byteLength(JSON.stringify(evidence)) > 16_384)
|
|
117
|
-
throw
|
|
125
|
+
throw fail("prepare", "execution_metadata_limit_exceeded");
|
|
118
126
|
return result;
|
|
119
127
|
};
|
|
120
128
|
try {
|
|
121
129
|
clean();
|
|
122
130
|
if (environment.prepare) {
|
|
131
|
+
const prepareStarted = performance.now();
|
|
123
132
|
const result = await execute(environment.prepare, "prepare");
|
|
124
133
|
if (result.exit_code !== 0 || result.timed_out || result.output_limited)
|
|
125
|
-
throw new QaPreparationError("prepare", ids, result.timed_out ? "timeout" : result.output_limited ? "output_limit" : "nonzero_exit"
|
|
134
|
+
throw new QaPreparationError("prepare", ids, result.timed_out ? "timeout" : result.output_limited ? "output_limit" : "nonzero_exit",
|
|
135
|
+
diagnostics("prepare", result.timed_out ? "timeout" : result.output_limited ? "output_limit" : "nonzero_exit", group, "prepare", Math.round(performance.now() - prepareStarted), result));
|
|
126
136
|
}
|
|
127
137
|
for (const item of group) {
|
|
128
|
-
const started =
|
|
129
|
-
const progress = (phase: QaVerificationProgressInput["phase"]) => options.onProgress?.({
|
|
130
|
-
index: item.index + 1, total: snapshot.acceptance.length, acceptance_id: item.id, phase, elapsed_ms:
|
|
138
|
+
const started = performance.now();
|
|
139
|
+
const progress = (phase: QaVerificationProgressInput["phase"], outcome: QaCheckDiagnostic["outcome"], result?: { exit_code: number; stdout: string; stderr: string }) => options.onProgress?.({
|
|
140
|
+
index: item.index + 1, total: snapshot.acceptance.length, acceptance_id: item.id, phase, elapsed_ms: Math.round(performance.now() - started),
|
|
141
|
+
diagnostic: diagnostics("check", outcome, [item], "check", Math.round(performance.now() - started), result)[0],
|
|
131
142
|
});
|
|
132
|
-
progress("running");
|
|
133
|
-
const result = await execute(item.descriptor.command, "check");
|
|
143
|
+
progress("running", "running");
|
|
144
|
+
const result = await execute(item.descriptor.command, "check", [item]);
|
|
134
145
|
const failed = result.exit_code !== 0 || result.timed_out || result.output_limited;
|
|
135
|
-
progress(failed ? "failed" : "passed");
|
|
146
|
+
progress(failed ? "failed" : "passed", result.timed_out ? "timeout" : result.output_limited ? "output_limit" : failed ? "nonzero_exit" : "passed", result);
|
|
136
147
|
if (failed) findings.push({ id: qaFindingId(item.id, snapshotDigest(snapshot)), kind: "blocking", acceptance_id: item.id,
|
|
137
148
|
summary: `verification failed (exit ${result.exit_code}${result.timed_out ? ", timed out" : ""}${result.output_limited ? ", output limit" : ""}) stdout=${Buffer.byteLength(result.stdout)}B stderr=${Buffer.byteLength(result.stderr)}B`, findings_digest: "" });
|
|
138
149
|
}
|
|
139
150
|
// Recheck while project entries still exist; cleanup removes them.
|
|
140
|
-
|
|
141
|
-
if (frozen?.entry) assertCommandIdentity(frozen);
|
|
142
|
-
}
|
|
143
|
-
} finally {
|
|
151
|
+
try {
|
|
152
|
+
for (const frozen of groupCommands) if (frozen?.entry) assertCommandIdentity(frozen);
|
|
153
|
+
} catch { throw new QaPreparationError("integrity", ids, "command_identity_changed", diagnostics("integrity", "command_identity_changed")); }
|
|
154
|
+
} finally {
|
|
155
|
+
try { try { delivery.cleanup(); } finally { removeTaskOwnedTree(home); } }
|
|
156
|
+
catch { throw new QaPreparationError("integrity", ids, "delivery_cleanup_failed", diagnostics("integrity", "delivery_cleanup_failed")); }
|
|
157
|
+
}
|
|
144
158
|
}
|
|
145
159
|
live();
|
|
146
160
|
// Host entries survive materialization cleanup and must still match before approval.
|
|
147
|
-
for (const
|
|
148
|
-
if (frozen?.entry) assertCommandIdentity(frozen);
|
|
161
|
+
for (const entry of allCommands) {
|
|
162
|
+
try { if (entry.frozen?.entry) assertCommandIdentity(entry.frozen); }
|
|
163
|
+
catch { throw new QaPreparationError("integrity", entry.diagnostics.map(item => item.acceptance_id), "command_identity_changed", entry.diagnostics); }
|
|
149
164
|
}
|
|
150
165
|
const base = { contract: "assurance_kernel/assurance_verdict/v2" as const, role: "qa" as const,
|
|
151
166
|
task_id: snapshot.task_id, snapshot_digest: snapshotDigest(snapshot) };
|
|
@@ -62,6 +62,7 @@ import {
|
|
|
62
62
|
} from "../unattended/batch_preflight";
|
|
63
63
|
import {
|
|
64
64
|
startBatch,
|
|
65
|
+
batchQaFailureFacts,
|
|
65
66
|
type BatchRunnerKernelPort,
|
|
66
67
|
type BatchRunReport,
|
|
67
68
|
} from "../unattended/batch_runner";
|
|
@@ -658,6 +659,13 @@ export class ClaudeRuntime {
|
|
|
658
659
|
}
|
|
659
660
|
}
|
|
660
661
|
|
|
662
|
+
async reviseIntent(taskId: string, nextIntent: unknown) {
|
|
663
|
+
return this.executeOrdinary({ cwd: this.cwd }, {
|
|
664
|
+
taskId,
|
|
665
|
+
operation: { op: "revise_intent", next_intent: nextIntent, actor_id: "executor" },
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
|
|
661
669
|
/**
|
|
662
670
|
* Ordinary Kernel operation, not a privileged one: canary_application builds
|
|
663
671
|
* the action without a capability and the Pi Host lists resolve_finding in
|
|
@@ -994,7 +1002,7 @@ export class ClaudeRuntime {
|
|
|
994
1002
|
// availability, working-tree cleanliness against the authorized scope,
|
|
995
1003
|
// recovery children, plan digest, and base HEAD are one shared projection,
|
|
996
1004
|
// so neither Host re-implements a batch decision.
|
|
997
|
-
|
|
1005
|
+
let now = new Date().toISOString();
|
|
998
1006
|
const preflight = await projectBatchPreflight({
|
|
999
1007
|
root: this.cwd,
|
|
1000
1008
|
initiative_slug: initiativeSlug,
|
|
@@ -1119,6 +1127,9 @@ export class ClaudeRuntime {
|
|
|
1119
1127
|
// review-1: verify cancellation signal right before authority issuance and startBatch
|
|
1120
1128
|
if (meta.signal?.aborted) return batchReason("cancelled_before_execution");
|
|
1121
1129
|
|
|
1130
|
+
now = new Date().toISOString();
|
|
1131
|
+
if (Date.parse(expiresAt) <= Date.parse(now))
|
|
1132
|
+
return batchReason("confirmation_failed", "confirmed batch deadline expired before authorization issuance");
|
|
1122
1133
|
const capability = this.batchRegistry.issue(binding, recoveryChildren as any, now);
|
|
1123
1134
|
|
|
1124
1135
|
const basePort = this.createBatchKernelPort(this.batchRegistry, capability, binding);
|
|
@@ -1202,14 +1213,22 @@ export class ClaudeRuntime {
|
|
|
1202
1213
|
},
|
|
1203
1214
|
advanceTask: async (root, taskId) => {
|
|
1204
1215
|
const result = await this.coordinator.advance(taskId, { cwd: root });
|
|
1205
|
-
|
|
1206
|
-
if (result.state === "
|
|
1207
|
-
if (result.state === "
|
|
1208
|
-
if (result.state === "
|
|
1209
|
-
if (result.state === "
|
|
1210
|
-
return { state:
|
|
1216
|
+
const facts = { diagnostics: result.diagnostics, environment_failure: result.environment_failure, recovery: result.recovery };
|
|
1217
|
+
if (result.state === "completed") return { state: "completed", ...facts };
|
|
1218
|
+
if (result.state === "stopped") return { state: "stopped", ...facts };
|
|
1219
|
+
if (result.state === "rework") return { state: "rework", operation: result.operation, summary: result.summary, ...facts };
|
|
1220
|
+
if (result.state === "review_ready") return { state: "review_ready", operation_id: result.operation_id, ...facts };
|
|
1221
|
+
if (result.state === "review_preparation_failed") return { state: result.state, operation: result.operation, operation_id: result.operation_id, reason: result.reason, ...facts };
|
|
1222
|
+
if (result.state === "blocked") return { state: "blocked", reason: result.reason, ...facts };
|
|
1223
|
+
return { state: "failed", reason: (result as { reason?: string }).reason ?? "advance failed", ...facts };
|
|
1224
|
+
},
|
|
1225
|
+
projectTask: async (root, taskId) => {
|
|
1226
|
+
const fresh = await projectAssurance(root, taskId, diffSnapshotOf);
|
|
1227
|
+
if (!fresh.claim || fresh.projection.lifecycle !== "active" || fresh.error !== null) return fresh;
|
|
1228
|
+
const { record, revision } = readTaskRecordRaw(root, taskId);
|
|
1229
|
+
if (!record || revision !== fresh.projection.record_revision) throw new Error(`Kernel recovery projection changed for ${taskId}`);
|
|
1230
|
+
return { ...fresh, ...batchQaFailureFacts(record) };
|
|
1211
1231
|
},
|
|
1212
|
-
projectTask: async (root, taskId) => projectAssurance(root, taskId, diffSnapshotOf),
|
|
1213
1232
|
ownsTaskClaim: (taskId) => {
|
|
1214
1233
|
return registry.isChildConsumed(capability, taskId);
|
|
1215
1234
|
},
|