immune-brain 4.5.0 → 4.6.0
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 +3 -2
- package/package.json +1 -1
- package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
- package/plugins/immune-brain/.pi-extension/imm-canary-enroll.ts +0 -1
- package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +47 -2
- package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +23 -18
- package/plugins/immune-brain/.pi-extension/runtime-stub.ts +0 -3
- package/plugins/immune-brain/dist/claude/mcp-server.mjs +679 -762
- package/plugins/immune-brain/dist/imm-loop.md +11 -4
- package/plugins/immune-brain/dist/imm-planner.md +37 -12
- package/plugins/immune-brain/runtime/assurance/coordinator.ts +55 -2
- package/plugins/immune-brain/runtime/assurance/review_evidence.ts +4 -0
- package/plugins/immune-brain/runtime/claude/interaction.ts +1 -2
- package/plugins/immune-brain/runtime/claude/kernel_ports.ts +73 -27
- package/plugins/immune-brain/runtime/claude/mcp_server.ts +3 -4
- package/plugins/immune-brain/runtime/claude/review_host.ts +15 -0
- package/plugins/immune-brain/runtime/github_issue_tracker.ts +738 -677
- package/plugins/immune-brain/runtime/kernel/application.ts +2 -5
- package/plugins/immune-brain/runtime/kernel/authority_port.ts +5 -14
- package/plugins/immune-brain/runtime/kernel/batch_authority.ts +30 -49
- package/plugins/immune-brain/runtime/kernel/canary_application.ts +1 -2
- package/plugins/immune-brain/runtime/kernel/canary_eligibility.ts +0 -1
- package/plugins/immune-brain/runtime/kernel/capability_registry.ts +7 -9
- package/plugins/immune-brain/runtime/kernel/enrollment.ts +21 -12
- package/plugins/immune-brain/runtime/kernel/enrollment_authority.ts +5 -11
- package/plugins/immune-brain/runtime/kernel/types.ts +2 -1
- package/plugins/immune-brain/runtime/kernel/validation.ts +3 -1
- package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
- package/plugins/immune-brain/runtime/unattended/batch_git.ts +9 -1
- package/plugins/immune-brain/runtime/unattended/batch_plan.ts +3 -9
- package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +120 -31
- package/plugins/immune-brain/runtime/unattended/batch_reasons.ts +0 -6
- package/plugins/immune-brain/runtime/unattended/batch_reconfirmation.ts +6 -7
- package/plugins/immune-brain/runtime/unattended/batch_runner.ts +72 -119
- package/plugins/immune-brain/runtime/unattended/batch_state.ts +36 -14
- package/plugins/immune-brain/runtime/unattended/types.ts +0 -2
- package/plugins/immune-brain/runtime/workspace_scope.ts +44 -8
- package/plugins/immune-brain/runtime/unattended/confirmation_deadline.ts +0 -33
|
@@ -130,11 +130,18 @@ The runtime owns no model invocation and adds no persisted execution lifecycle.
|
|
|
130
130
|
After diagnostics pass, the Parent advances Kernel QA and dispatches any reserved
|
|
131
131
|
foreground Review once using the exact returned `agent_params`, then submits its
|
|
132
132
|
structured verdict. An open Review reservation stays with that Host invocation;
|
|
133
|
-
batch re-entry waits for it rather than redispatching.
|
|
134
|
-
|
|
135
|
-
|
|
133
|
+
batch re-entry waits for it rather than redispatching. When a batch child's
|
|
134
|
+
foreground terminal mutation reaches Kernel `done`, the same tool call stages
|
|
135
|
+
only that child's generated terminal audit evidence and re-enters the batch under
|
|
136
|
+
the still-valid authorization, opening no gate. The driver reconciles settlement,
|
|
136
137
|
adopts or creates the one scope-bound commit, then enrolls the next ready child
|
|
137
|
-
|
|
138
|
+
or settles the batch as `completed`; the result carries the batch report as
|
|
139
|
+
`batch`. A reserved Review, a parked child and a stopped child end where they do
|
|
140
|
+
today and are never committed automatically. If the re-entry fails, the Kernel
|
|
141
|
+
result is unchanged and `batch` carries one recovery action: call
|
|
142
|
+
`start_unattended_batch` with the same slug, which is also how to continue by
|
|
143
|
+
hand. A commit that fast-forwards the batch branch outside the runner is adopted,
|
|
144
|
+
not attributed to a child; any other HEAD movement still fails closed. Ordinary Executor handoff and below-limit
|
|
138
145
|
QA repair do not park the batch; genuine budget/authority stops remain fail-closed.
|
|
139
146
|
|
|
140
147
|
Batch execution never pushes a ref, opens or updates a pull request, resolves a
|
|
@@ -273,8 +273,21 @@ goal prose and never truncates a title. The tracker rereads every canonical
|
|
|
273
273
|
TaskIntent for identity, risk, and acceptance IDs; projection fields and public
|
|
274
274
|
summaries never widen TaskIntent scope or authority. It validates the complete dependency graph before
|
|
275
275
|
remote writes, creates the Parent once, creates all Children, attaches every
|
|
276
|
-
Child as a native Sub-issue, creates native `blocked_by` relations, and
|
|
277
|
-
|
|
276
|
+
Child as a native Sub-issue, creates native `blocked_by` relations, and takes
|
|
277
|
+
every Issue number from its own create response. The write flow is direct: one
|
|
278
|
+
start-of-run read of the repository identity, the Issue listing, and the label
|
|
279
|
+
listing, then one create per absent Issue in dependency order, one attach per
|
|
280
|
+
Child, and one relation write per edge. No repository listing, attachment,
|
|
281
|
+
dependency or ownership confirmation follows a write; one Issue-scoped read
|
|
282
|
+
follows each create to resolve the database id the relation endpoints need,
|
|
283
|
+
because `gh issue create` returns only the Issue URL. No cumulative deadline
|
|
284
|
+
exists either; each call keeps its own per-call timeout and
|
|
285
|
+
the caller's cancellation. Deduplication is start-only: a rerun of the same
|
|
286
|
+
approved batch adopts whatever the start listing already carries and writes only
|
|
287
|
+
the missing relations, so it never creates a duplicate Issue and never repeats a
|
|
288
|
+
completed write. Concurrent edits made during a run are not detected in that
|
|
289
|
+
run; they surface as drift at the start of the next run. Every Child carries
|
|
290
|
+
`ready-for-agent`, blocked Children
|
|
278
291
|
additionally carry `blocked`, and the Parent carries neither; the tracker never
|
|
279
292
|
creates labels, so a repository missing a required label fails the batch closed
|
|
280
293
|
before any remote write. The Child Agent Brief includes a direct Parent Issue link.
|
|
@@ -298,27 +311,39 @@ complete-decomposition decision; or `tracker_projection_failed` with the returne
|
|
|
298
311
|
failure and exact retry action. A candidate Initiative or partial Issue set
|
|
299
312
|
recorded only in the Spec or final summary is neither user confirmation nor a
|
|
300
313
|
completed carrier outcome. Report `retryable_failure`, `permanent_failure`, or
|
|
301
|
-
`ambiguous_remote_state` and the exact batch retry action.
|
|
314
|
+
`ambiguous_remote_state` and the exact batch retry action. A failure after the
|
|
315
|
+
request may have landed is reported as uncertain with its confirmed steps, its
|
|
316
|
+
pending steps, and one recovery action: rerun the same approved batch, whose
|
|
317
|
+
start listing adopts whatever landed. This does not invalidate
|
|
302
318
|
already-authored planning files, but it blocks `tracker_associated` and every
|
|
303
319
|
Enrollment or execution handoff for that Initiative until the same complete
|
|
304
|
-
batch succeeds. Do not infer opt-in from tracker output or Issue state,
|
|
305
|
-
import Issue state, create a TaskIntent from an Issue, or store Issue identity in
|
|
320
|
+
batch succeeds. Do not infer opt-in from tracker output or Issue state, import Issue state, create a TaskIntent from an Issue, or store Issue identity in
|
|
306
321
|
TaskIntent or TaskRecord. Existing Issue markers grant permission only for
|
|
307
322
|
idempotent retry of that same approved Initiative; they never grant execution
|
|
308
323
|
authority.
|
|
309
324
|
|
|
325
|
+
The Planner never closes the Parent. The terminal projection of a Child does: when
|
|
326
|
+
`mark-terminal` closes a Child as completed and every Slice Child of that Initiative
|
|
327
|
+
is closed as completed, the tracker closes the Parent as completed; an open, stopped
|
|
328
|
+
or parked Child keeps it open.
|
|
329
|
+
|
|
310
330
|
For amending an already-published Initiative, the same
|
|
311
331
|
`imm-tracker publish-initiative --stdin --json` operation accepts an optional
|
|
312
332
|
`amendment` input after the literal user approves the changed pending frontier.
|
|
313
333
|
Provide the complete approved pending Tasks plus the complete read-only
|
|
314
334
|
historical Child list, each binding the observed `issue_number`, title, body,
|
|
315
|
-
and state at approval. The
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
335
|
+
and state at approval. The amendment uses the same direct write flow: one start
|
|
336
|
+
listing (plus Issue-scoped Sub-issue and `blocked_by` reads) verifies every bound
|
|
337
|
+
Issue against its baseline, then the Parent is edited at most once, each approved
|
|
338
|
+
pending brief is edited at most once, newly added pending Children are created as
|
|
339
|
+
in ordinary publication, and each pending Child's `blocked_by` set is converged
|
|
340
|
+
to the approved set. Historical Children are never written and keep their
|
|
341
|
+
byte-exact content, native Sub-issue links and dependency relations. No read
|
|
342
|
+
follows a write and no closing recheck runs, so baseline drift that appears after
|
|
343
|
+
the start listing is not detected in that run; it surfaces at the start of the
|
|
344
|
+
next run. Omitted membership, baseline drift present at start, and a stopped
|
|
345
|
+
historical prerequisite fail closed before any write with an exact retry action;
|
|
346
|
+
the strict no-amendment default is unchanged.
|
|
322
347
|
|
|
323
348
|
### Verification Descriptor Discipline
|
|
324
349
|
|
|
@@ -316,7 +316,10 @@ export interface ForegroundToolUpdate {
|
|
|
316
316
|
details: Record<string, unknown>;
|
|
317
317
|
}
|
|
318
318
|
|
|
319
|
+
/** Wall-clock milliseconds spent per advance stage; the stage names are the progress stages. */
|
|
320
|
+
export interface AssuranceTimings { total_ms: number; stage_ms: Record<string, number> }
|
|
319
321
|
type AssuranceRecoveryFields = {
|
|
322
|
+
timings?: AssuranceTimings;
|
|
320
323
|
diagnostics?: QaCheckDiagnostic[];
|
|
321
324
|
environment_failure?: boolean;
|
|
322
325
|
recovery?: AssuranceRecovery;
|
|
@@ -404,12 +407,23 @@ export interface AssuranceCoordinatorPorts {
|
|
|
404
407
|
qaOnAuthorityCommit?: () => void;
|
|
405
408
|
qaAfterAuthorityCommit?: () => Promise<void>;
|
|
406
409
|
qaJobTimeoutMs?: number;
|
|
410
|
+
/** Attach per-stage `timings` to advance results. Defaults to IMM_ASSURANCE_TIMINGS=1. */
|
|
411
|
+
reportTimings?: boolean;
|
|
412
|
+
/** Overrides QA_MAX_IDENTICAL_FAILURES; tests only. */
|
|
413
|
+
qaMaxIdenticalFailures?: number;
|
|
407
414
|
}
|
|
408
415
|
|
|
409
416
|
export const QA_MIN_JOB_TIMEOUT_SECONDS = 15 * 60;
|
|
410
417
|
export const QA_MAX_JOB_TIMEOUT_SECONDS = 60 * 60;
|
|
411
418
|
export const QA_JOB_OVERHEAD_SECONDS = 2 * 60;
|
|
412
419
|
export const QA_JOB_TIMEOUT_SECONDS = QA_MIN_JOB_TIMEOUT_SECONDS;
|
|
420
|
+
/**
|
|
421
|
+
* A QA run that fails before attestation writes no authority, so the Kernel
|
|
422
|
+
* would happily rerun the same descriptors forever. After this many identical
|
|
423
|
+
* failures on the same snapshot (record revision, intent hash, diff hash) the
|
|
424
|
+
* coordinator refuses to rerun until the snapshot changes.
|
|
425
|
+
*/
|
|
426
|
+
export const QA_MAX_IDENTICAL_FAILURES = 2;
|
|
413
427
|
export const REVIEW_PREPARATION_TIMEOUT_MS = 30_000;
|
|
414
428
|
export const REVIEW_DISPATCH_TIMEOUT_MS = 120_000;
|
|
415
429
|
export const REVIEW_VERDICT_VALIDATION_TIMEOUT_MS = 30_000;
|
|
@@ -705,6 +719,8 @@ export class AssuranceCoordinator {
|
|
|
705
719
|
private readonly operationControllers = new Map<string, { operationId: string; controller: AbortController }>();
|
|
706
720
|
private readonly reviewReservations = new Map<string, ReviewReservation>();
|
|
707
721
|
private readonly rejectedReviewOperations = new Map<string, { operationId: string; reason: string }>();
|
|
722
|
+
private readonly qaFailureStreaks = new Map<string, { key: string; count: number }>();
|
|
723
|
+
private readonly qaAttemptKeys = new Map<string, string>();
|
|
708
724
|
private readonly unknownOperations = new Map<string, { operation: "qa" | "review"; operationId: string; reason: string }>();
|
|
709
725
|
private readonly sessionInvocations = new Set<InvocationToken>();
|
|
710
726
|
private sessionActive = true;
|
|
@@ -731,6 +747,8 @@ export class AssuranceCoordinator {
|
|
|
731
747
|
this.removeEvidence(reservation);
|
|
732
748
|
}
|
|
733
749
|
this.reviewReservations.clear();
|
|
750
|
+
this.qaFailureStreaks.clear();
|
|
751
|
+
this.qaAttemptKeys.clear();
|
|
734
752
|
for (const invocation of [...this.sessionInvocations]) this.closeSessionInvocation(invocation);
|
|
735
753
|
}
|
|
736
754
|
|
|
@@ -762,7 +780,19 @@ export class AssuranceCoordinator {
|
|
|
762
780
|
|
|
763
781
|
async advance(taskId: string, ctx: HostContext, signal?: AbortSignal, onUpdate?: (update: ForegroundToolUpdate) => void): Promise<AssuranceAdvanceResult> {
|
|
764
782
|
const checks = new Map<string, QaCheckDiagnostic>();
|
|
783
|
+
const started = performance.now();
|
|
784
|
+
const stageMs: Record<string, number> = {};
|
|
785
|
+
let stage = "preparing";
|
|
786
|
+
let stageStarted = started;
|
|
787
|
+
const closeStage = (now: number) => { stageMs[stage] = (stageMs[stage] ?? 0) + Math.round(now - stageStarted); };
|
|
765
788
|
const result = await this.advanceOnce(taskId, ctx, signal, update => {
|
|
789
|
+
const next = update.details.stage;
|
|
790
|
+
if (typeof next === "string" && next !== stage) {
|
|
791
|
+
const now = performance.now();
|
|
792
|
+
closeStage(now);
|
|
793
|
+
stage = next;
|
|
794
|
+
stageStarted = now;
|
|
795
|
+
}
|
|
766
796
|
const diagnostic = update.details.diagnostic as QaCheckDiagnostic | undefined;
|
|
767
797
|
if (diagnostic) {
|
|
768
798
|
checks.set(diagnostic.descriptor_ref, diagnostic);
|
|
@@ -772,8 +802,25 @@ export class AssuranceCoordinator {
|
|
|
772
802
|
}
|
|
773
803
|
onUpdate?.(update);
|
|
774
804
|
});
|
|
805
|
+
this.recordQaAttempt(taskId, result);
|
|
775
806
|
const diagnostics = result.diagnostics ?? [...checks.values()];
|
|
776
|
-
|
|
807
|
+
const enriched = diagnostics.length ? { ...result, diagnostics } : result;
|
|
808
|
+
if (!(this.ports.reportTimings ?? process.env.IMM_ASSURANCE_TIMINGS === "1")) return this.withRecovery(taskId, ctx, enriched);
|
|
809
|
+
const finished = performance.now();
|
|
810
|
+
closeStage(finished);
|
|
811
|
+
return this.withRecovery(taskId, ctx, { ...enriched, timings: { total_ms: Math.round(finished - started), stage_ms: stageMs } });
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
private recordQaAttempt(taskId: string, result: AssuranceAdvanceResult): void {
|
|
815
|
+
const key = this.qaAttemptKeys.get(taskId);
|
|
816
|
+
this.qaAttemptKeys.delete(taskId);
|
|
817
|
+
if (!key || result.state === "cancelled") return;
|
|
818
|
+
if (result.state !== "failed" || result.operation !== "qa") {
|
|
819
|
+
this.qaFailureStreaks.delete(taskId);
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
const streak = this.qaFailureStreaks.get(taskId);
|
|
823
|
+
this.qaFailureStreaks.set(taskId, { key, count: streak?.key === key ? streak.count + 1 : 1 });
|
|
777
824
|
}
|
|
778
825
|
|
|
779
826
|
private async withRecovery<T extends AssuranceAdvanceResult | AssuranceSubmitReviewResult>(taskId: string, ctx: HostContext, enriched: T): Promise<T> {
|
|
@@ -947,7 +994,13 @@ export class AssuranceCoordinator {
|
|
|
947
994
|
// Preparation counts against the same ceiling as the checks, so the
|
|
948
995
|
// aggregate clock starts before the first prepared group runs. A host
|
|
949
996
|
// may only tighten the derived budget, never raise it past the ceiling.
|
|
950
|
-
const
|
|
997
|
+
const qaKey = `${assurance.snapshot.record_revision}|${assurance.snapshot.intent_content_hash}|${assurance.snapshot.diff_hash}`;
|
|
998
|
+
const streak = this.qaFailureStreaks.get(taskId);
|
|
999
|
+
const maxFailures = this.ports.qaMaxIdenticalFailures ?? QA_MAX_IDENTICAL_FAILURES;
|
|
1000
|
+
if (streak?.key === qaKey && streak.count >= maxFailures)
|
|
1001
|
+
return { state: "blocked", reason: `deterministic QA already failed ${streak.count} times on this exact snapshot; change the workspace diff or intent, or restart the session after fixing the environment, before retrying` };
|
|
1002
|
+
this.qaAttemptKeys.set(taskId, qaKey);
|
|
1003
|
+
const declaredQaJobMs = deriveQaJobTimeoutMs(assurance.descriptors.values());
|
|
951
1004
|
const qaJobTimeoutMs = Math.min(declaredQaJobMs, this.ports.qaJobTimeoutMs ?? declaredQaJobMs);
|
|
952
1005
|
const qaJobDeadline = setTimeout(() => {
|
|
953
1006
|
qaJobBudgetExceeded = true;
|
|
@@ -463,6 +463,10 @@ function publishInput(
|
|
|
463
463
|
const recomputed = `sha256:${createHash("sha256").update(JSON.stringify(snapshot)).digest("hex")}`;
|
|
464
464
|
if (recomputed !== input.expectedDiffHash)
|
|
465
465
|
throw new Error("review task revision does not match assurance snapshot");
|
|
466
|
+
// A revision whose tree equals the base carries nothing to review; reserving
|
|
467
|
+
// Review for it would settle QA outcomes against bytes it does not contain.
|
|
468
|
+
if (Object.keys(snapshot.changed_paths).length === 0)
|
|
469
|
+
throw new Error("review revision carries no task change; stage the task's in-scope work before Review");
|
|
466
470
|
return { snapshot, revision: publishReviewRevision(snapshot.repository_root, snapshot, recomputed, input.taskId) };
|
|
467
471
|
}
|
|
468
472
|
|
|
@@ -81,8 +81,7 @@ export interface NativeConfirmationInput {
|
|
|
81
81
|
batch_branch: string;
|
|
82
82
|
children: Array<{ task_id: string; slice_id: string; risk?: string }>;
|
|
83
83
|
excluded: Array<{ task_id: string; slice_id: string; reason: string }>;
|
|
84
|
-
budget: { max_children: number;
|
|
85
|
-
expires_at: string;
|
|
84
|
+
budget: { max_children: number; qa_failure_limit: number };
|
|
86
85
|
};
|
|
87
86
|
signal?: AbortSignal;
|
|
88
87
|
}
|
|
@@ -44,7 +44,6 @@ import { preparePiCanary, revalidatePiCanary } from "../kernel/pi_canary_prepare
|
|
|
44
44
|
import { runDeterministicQa } from "../assurance/qa";
|
|
45
45
|
import { taskDiffIdentity, taskRevisionIdentity } from "../workspace_scope";
|
|
46
46
|
import { batchReason } from "../unattended/batch_reasons";
|
|
47
|
-
import { startConfirmationDeadline } from "../unattended/confirmation_deadline";
|
|
48
47
|
import { deriveAuthorizationOperation } from "../authorization_operation";
|
|
49
48
|
import { LITERAL_USER_ACTOR_ID, canonicalActorId } from "../kernel/actor_identity";
|
|
50
49
|
import {
|
|
@@ -59,6 +58,7 @@ import {
|
|
|
59
58
|
projectBatchPreflight,
|
|
60
59
|
readActiveClaimTaskId,
|
|
61
60
|
isOwnBatchClaim,
|
|
61
|
+
findResumableBatchSlugForTask,
|
|
62
62
|
} from "../unattended/batch_preflight";
|
|
63
63
|
import {
|
|
64
64
|
startBatch,
|
|
@@ -391,7 +391,6 @@ async function mintCapability(
|
|
|
391
391
|
diff_hash: input.diff_hash,
|
|
392
392
|
actor_id: input.actor_id,
|
|
393
393
|
confirmation_ref: input.confirmation_ref,
|
|
394
|
-
expires_at: new Date(Date.now() + 10 * 60 * 1000).toISOString(),
|
|
395
394
|
findings_digest: input.action_kind === "request_rework"
|
|
396
395
|
? findingsDigestV2(input.findings as TaskFinding[])
|
|
397
396
|
: null,
|
|
@@ -587,7 +586,6 @@ export class ClaudeRuntime {
|
|
|
587
586
|
preparation_digest: preparation.digest,
|
|
588
587
|
actor_id: LITERAL_USER_ACTOR_ID,
|
|
589
588
|
confirmation_ref: gate.confirmation_ref,
|
|
590
|
-
expires_at: new Date(Date.now() + 10 * 60 * 1000).toISOString(),
|
|
591
589
|
nonce,
|
|
592
590
|
};
|
|
593
591
|
const capability = this.enrollmentRegistry.issue(binding);
|
|
@@ -611,20 +609,65 @@ export class ClaudeRuntime {
|
|
|
611
609
|
}
|
|
612
610
|
}
|
|
613
611
|
|
|
614
|
-
async advance(taskId: string, signal?: AbortSignal) {
|
|
615
|
-
return this.
|
|
612
|
+
async advance(taskId: string, signal?: AbortSignal, meta?: ToolMeta) {
|
|
613
|
+
return this.closeOutBatchChild(
|
|
616
614
|
taskId,
|
|
617
|
-
await this.coordinator.advance(taskId, { cwd: this.cwd }, signal),
|
|
615
|
+
await this.withTerminalTracker(taskId, await this.coordinator.advance(taskId, { cwd: this.cwd }, signal)),
|
|
616
|
+
meta,
|
|
618
617
|
);
|
|
619
618
|
}
|
|
620
619
|
|
|
621
|
-
async submitReview(taskId: string, verdictInput: unknown) {
|
|
622
|
-
return this.
|
|
620
|
+
async submitReview(taskId: string, verdictInput: unknown, meta?: ToolMeta) {
|
|
621
|
+
return this.closeOutBatchChild(
|
|
623
622
|
taskId,
|
|
624
|
-
await
|
|
623
|
+
await this.withTerminalTracker(
|
|
624
|
+
taskId,
|
|
625
|
+
await submitClaudeReview(this.host, this.coordinator, { cwd: this.cwd }, taskId, verdictInput),
|
|
626
|
+
),
|
|
627
|
+
meta,
|
|
625
628
|
);
|
|
626
629
|
}
|
|
627
630
|
|
|
631
|
+
/**
|
|
632
|
+
* Gate-free closeout: a batch child that reached Kernel `done` in the
|
|
633
|
+
* foreground re-enters the batch in the same call, reusing its authorization.
|
|
634
|
+
* Only `done` continues; a stopped child, a parked child and a live Review
|
|
635
|
+
* obligation end where they do today. The continuation is transport: the
|
|
636
|
+
* Kernel result is returned unchanged, and any failure is reported beside it
|
|
637
|
+
* with one retry action, never thrown into a path that could read as "the
|
|
638
|
+
* mutation did not happen".
|
|
639
|
+
*/
|
|
640
|
+
private async closeOutBatchChild<T>(taskId: string, result: T, meta?: ToolMeta): Promise<T> {
|
|
641
|
+
if (!meta || result === null || typeof result !== "object") return result;
|
|
642
|
+
const lifecycle = (result as { record?: { lifecycle?: unknown } }).record?.lifecycle;
|
|
643
|
+
if ((result as { state?: unknown }).state !== "completed" && lifecycle !== "done") return result;
|
|
644
|
+
let slug: string | null;
|
|
645
|
+
try {
|
|
646
|
+
slug = findResumableBatchSlugForTask(this.cwd, taskId);
|
|
647
|
+
} catch {
|
|
648
|
+
return result;
|
|
649
|
+
}
|
|
650
|
+
if (!slug) return result;
|
|
651
|
+
try {
|
|
652
|
+
// The batch commit takes exactly the scope envelope plus this task's own
|
|
653
|
+
// audit evidence, and its preflight refuses unstaged changes: stage only
|
|
654
|
+
// that evidence, the step an Executor otherwise does by hand.
|
|
655
|
+
if (existsSync(join(this.cwd, ".imm", "audit", taskId)))
|
|
656
|
+
execFileSync("git", ["-C", this.cwd, "add", "--", `.imm/audit/${taskId}`], { stdio: "ignore" });
|
|
657
|
+
const batch = await this.startUnattendedBatch(slug, meta, { reuseOnly: true });
|
|
658
|
+
return { ...(result as object), batch } as T;
|
|
659
|
+
} catch (error) {
|
|
660
|
+
return {
|
|
661
|
+
...(result as object),
|
|
662
|
+
batch: {
|
|
663
|
+
state: "rejected",
|
|
664
|
+
reason: `batch continuation failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
665
|
+
recovery_action: "call start_unattended_batch with the same Initiative to continue the batch",
|
|
666
|
+
},
|
|
667
|
+
} as T;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
628
671
|
/**
|
|
629
672
|
* Post-settlement GitHub tracker projection: the same shared step the Pi Host
|
|
630
673
|
* runs, so an opted-in terminal projection no longer depends on which Host
|
|
@@ -987,15 +1030,18 @@ export class ClaudeRuntime {
|
|
|
987
1030
|
async startUnattendedBatch(
|
|
988
1031
|
initiativeSlug: string,
|
|
989
1032
|
meta: ToolMeta,
|
|
1033
|
+
options: { reuseOnly?: boolean } = {},
|
|
990
1034
|
): Promise<ClaudeBatchStartResult> {
|
|
991
1035
|
throwIfCancelled(meta.signal);
|
|
1036
|
+
const reuseOnly = options.reuseOnly === true;
|
|
992
1037
|
const probe = probeHost(this.env, process.platform, this.hostVersion);
|
|
993
1038
|
if (!probe.ok) throw new NativeAuthorityError("unsupported_host", probe.reason);
|
|
1039
|
+
// A reuse-only continuation never opens a gate, so it needs no confirmation port.
|
|
994
1040
|
const interactive = meta.interactive ?? this.interactive;
|
|
995
|
-
if (!interactive) throw new NativeAuthorityError("unsupported_host", "interactive MCP elicitation is unavailable");
|
|
1041
|
+
if (!reuseOnly && !interactive) throw new NativeAuthorityError("unsupported_host", "interactive MCP elicitation is unavailable");
|
|
996
1042
|
// The text is table-owned; only the form (a thrown native error rather than
|
|
997
1043
|
// a returned envelope) is this Host's.
|
|
998
|
-
if (!this.requestConfirmation)
|
|
1044
|
+
if (!reuseOnly && !this.requestConfirmation)
|
|
999
1045
|
throw new NativeAuthorityError("interaction_not_opened", batchReason("confirmation_port_unavailable").reason);
|
|
1000
1046
|
|
|
1001
1047
|
// 1. Host-independent batch preflight: claim ownership, branch
|
|
@@ -1022,7 +1068,7 @@ export class ClaudeRuntime {
|
|
|
1022
1068
|
const budget = preflight.projection.budget;
|
|
1023
1069
|
const planDigest = preflight.projection.plan_digest;
|
|
1024
1070
|
const recoveryChildren = preflight.projection.recovery_children;
|
|
1025
|
-
// 5. Literal-user gate plus the shared reuse
|
|
1071
|
+
// 5. Literal-user gate plus the shared reuse decision, the post-gate
|
|
1026
1072
|
// claim/drift cascade, and the Batch Authorization binding. The Host supplies
|
|
1027
1073
|
// only its gate, its confirmation reference, and its binding nonce.
|
|
1028
1074
|
const authorization = await authorizeBatch<ClaudeBatchStartResult>({
|
|
@@ -1033,9 +1079,19 @@ export class ClaudeRuntime {
|
|
|
1033
1079
|
readInitiative: this.readInitiative ?? observeGithubInitiative,
|
|
1034
1080
|
nonce: enrollmentNonce(),
|
|
1035
1081
|
gate: async (facts) => {
|
|
1036
|
-
//
|
|
1037
|
-
//
|
|
1038
|
-
|
|
1082
|
+
// A continuation after a foreground child never opens a gate: when the
|
|
1083
|
+
// authorization cannot be reused, the user re-enters the batch tool.
|
|
1084
|
+
if (reuseOnly)
|
|
1085
|
+
return {
|
|
1086
|
+
kind: "host_rejection",
|
|
1087
|
+
value: {
|
|
1088
|
+
state: "blocked",
|
|
1089
|
+
reason: `batch authorization cannot be reused (${facts.reuse_blockers.join(", ")})`,
|
|
1090
|
+
recovery_action: "call start_unattended_batch with the same Initiative to confirm a fresh authorization",
|
|
1091
|
+
},
|
|
1092
|
+
};
|
|
1093
|
+
// The gate settles only on the literal user's answer or the caller's
|
|
1094
|
+
// cancellation signal.
|
|
1039
1095
|
let confirmationResult: { decision: NativeDecision; requestId: string };
|
|
1040
1096
|
try {
|
|
1041
1097
|
confirmationResult = await this.requestConfirmation!({
|
|
@@ -1057,7 +1113,6 @@ export class ClaudeRuntime {
|
|
|
1057
1113
|
reason: child.reason,
|
|
1058
1114
|
})),
|
|
1059
1115
|
budget: facts.budget,
|
|
1060
|
-
expires_at: facts.expires_at,
|
|
1061
1116
|
...(facts.reuse_blockers.length > 0
|
|
1062
1117
|
? {
|
|
1063
1118
|
re_confirmation_required: facts.reuse_blockers,
|
|
@@ -1065,10 +1120,9 @@ export class ClaudeRuntime {
|
|
|
1065
1120
|
}
|
|
1066
1121
|
: {}),
|
|
1067
1122
|
},
|
|
1068
|
-
signal:
|
|
1123
|
+
signal: meta.signal,
|
|
1069
1124
|
});
|
|
1070
1125
|
} catch (err) {
|
|
1071
|
-
if (deadline.timedOut()) return { kind: "host_rejection", value: batchReason("confirmation_timed_out") };
|
|
1072
1126
|
if (meta.signal?.aborted)
|
|
1073
1127
|
return { kind: "host_rejection", value: batchReason("cancelled_before_execution") };
|
|
1074
1128
|
if (err instanceof NativeAuthorityError) {
|
|
@@ -1088,13 +1142,8 @@ export class ClaudeRuntime {
|
|
|
1088
1142
|
kind: "host_rejection",
|
|
1089
1143
|
value: batchReason("confirmation_failed", err instanceof Error ? err.message : String(err)),
|
|
1090
1144
|
};
|
|
1091
|
-
} finally {
|
|
1092
|
-
deadline.clear();
|
|
1093
1145
|
}
|
|
1094
1146
|
|
|
1095
|
-
// A transport that answers "cancel" on abort is still a timeout, not a user
|
|
1096
|
-
// decision: the Host that owns the transport reports which one it was.
|
|
1097
|
-
if (deadline.timedOut()) return { kind: "host_rejection", value: batchReason("confirmation_timed_out") };
|
|
1098
1147
|
if (confirmationResult.decision === "cancel" && meta.signal?.aborted)
|
|
1099
1148
|
return { kind: "host_rejection", value: batchReason("confirmation_cancelled") };
|
|
1100
1149
|
if (meta.signal?.aborted)
|
|
@@ -1122,14 +1171,12 @@ export class ClaudeRuntime {
|
|
|
1122
1171
|
});
|
|
1123
1172
|
if (authorization.outcome === "host_rejection") return authorization.value;
|
|
1124
1173
|
if (authorization.outcome === "rejected") return authorization.rejection;
|
|
1125
|
-
const { binding, batch_id: batchId
|
|
1174
|
+
const { binding, batch_id: batchId } = authorization;
|
|
1126
1175
|
|
|
1127
1176
|
// review-1: verify cancellation signal right before authority issuance and startBatch
|
|
1128
1177
|
if (meta.signal?.aborted) return batchReason("cancelled_before_execution");
|
|
1129
1178
|
|
|
1130
1179
|
now = new Date().toISOString();
|
|
1131
|
-
if (Date.parse(expiresAt) <= Date.parse(now))
|
|
1132
|
-
return batchReason("confirmation_failed", "confirmed batch deadline expired before authorization issuance");
|
|
1133
1180
|
const capability = this.batchRegistry.issue(binding, recoveryChildren as any, now);
|
|
1134
1181
|
|
|
1135
1182
|
const basePort = this.createBatchKernelPort(this.batchRegistry, capability, binding);
|
|
@@ -1153,7 +1200,6 @@ export class ClaudeRuntime {
|
|
|
1153
1200
|
plan_digest: planDigest,
|
|
1154
1201
|
base_head: binding.base_head,
|
|
1155
1202
|
confirmation_time: now,
|
|
1156
|
-
authorization_expires_at: expiresAt,
|
|
1157
1203
|
budget,
|
|
1158
1204
|
now,
|
|
1159
1205
|
kernel: kernelPort,
|
|
@@ -160,10 +160,10 @@ export function createMcpRuntime(options: McpRuntimeOptions = {}) {
|
|
|
160
160
|
signal: meta.signal,
|
|
161
161
|
};
|
|
162
162
|
if (name === "enroll") return runtime.enroll(taskId, toolMeta);
|
|
163
|
-
if (name === "advance_assurance") return runtime.advance(taskId, toolMeta.signal);
|
|
163
|
+
if (name === "advance_assurance") return runtime.advance(taskId, toolMeta.signal, toolMeta);
|
|
164
164
|
if (name === "submit_review") {
|
|
165
165
|
if (!Object.hasOwn(args, "verdict")) throw new Error("verdict is required");
|
|
166
|
-
return runtime.submitReview(taskId, args.verdict);
|
|
166
|
+
return runtime.submitReview(taskId, args.verdict, toolMeta);
|
|
167
167
|
}
|
|
168
168
|
if (name === "revise_intent") {
|
|
169
169
|
if (!Object.hasOwn(args, "next_intent")) throw new Error("next_intent is required");
|
|
@@ -369,8 +369,7 @@ export function elicitationParams(input: NativeConfirmationInput) {
|
|
|
369
369
|
input.planDigest ? `Plan digest: ${input.planDigest}` : null,
|
|
370
370
|
b?.children ? `Ordered children (${b.children.length}):\n${b.children.map((c) => ` - ${c.task_id} (${c.slice_id}) [risk: ${c.risk ?? "unknown"}]`).join("\n")}` : null,
|
|
371
371
|
b?.excluded && b.excluded.length > 0 ? `Excluded children (${b.excluded.length}):\n${b.excluded.map((e) => ` - ${e.task_id} (${e.slice_id}): ${e.reason}`).join("\n")}` : null,
|
|
372
|
-
b?.budget ? `Budget: max_children=${b.budget.max_children},
|
|
373
|
-
b?.expires_at ? `Expires at: ${b.expires_at}` : null,
|
|
372
|
+
b?.budget ? `Budget: max_children=${b.budget.max_children}, qa_failure_limit=${b.budget.qa_failure_limit}` : null,
|
|
374
373
|
].filter(Boolean);
|
|
375
374
|
return {
|
|
376
375
|
mode: "form",
|
|
@@ -169,12 +169,21 @@ export function parseAsyncAgentLaunch(result: string): AsyncAgentLaunch | null {
|
|
|
169
169
|
return { agentId, outputFile };
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
+
const HANDBACK_TOOL = "SubagentHandback";
|
|
173
|
+
|
|
172
174
|
/**
|
|
173
175
|
* Extract the reviewer's terminal message from its own transcript.
|
|
174
176
|
*
|
|
175
177
|
* Each record carries the `agentId` that wrote it, so the caller's independently
|
|
176
178
|
* observed id is matched per record rather than trusted for the file as a whole;
|
|
177
179
|
* a transcript that interleaves another agent cannot contribute its text.
|
|
180
|
+
*
|
|
181
|
+
* A subagent ends its run either with an assistant text message or, on Hosts
|
|
182
|
+
* that hand the report back through a tool, with a `SubagentHandback` call whose
|
|
183
|
+
* `input.message` is that same report and which leaves no text block behind.
|
|
184
|
+
* Both are the reviewer's own record in its own transcript, so whichever the
|
|
185
|
+
* observed agent wrote last is its result — across records and across the
|
|
186
|
+
* ordered content blocks of a single record.
|
|
178
187
|
*/
|
|
179
188
|
export function readAgentTranscriptResult(transcript: string, agentId: string): string | null {
|
|
180
189
|
let last: string | null = null;
|
|
@@ -192,6 +201,12 @@ export function readAgentTranscriptResult(transcript: string, agentId: string):
|
|
|
192
201
|
if (!block || typeof block !== "object") continue;
|
|
193
202
|
const part = block as Record<string, unknown>;
|
|
194
203
|
if (part.type === "text" && typeof part.text === "string") text += part.text;
|
|
204
|
+
if (part.type === "tool_use" && part.name === HANDBACK_TOOL && part.input && typeof part.input === "object") {
|
|
205
|
+
const report = (part.input as Record<string, unknown>).message;
|
|
206
|
+
// Blocks are ordered, so text before the report is a preamble and
|
|
207
|
+
// only text written after it can supersede it.
|
|
208
|
+
if (typeof report === "string" && report.trim()) { last = report; text = ""; }
|
|
209
|
+
}
|
|
195
210
|
}
|
|
196
211
|
if (text.trim()) last = text;
|
|
197
212
|
}
|