@try-works/dsh-recursive-mode 0.3.1 → 0.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 +959 -0
- package/lib/client.js +9 -2
- package/lib/closeout-report.d.ts +113 -0
- package/lib/closeout-standards.d.ts +35 -0
- package/lib/closeout.d.ts +12 -0
- package/lib/commands.d.ts +1 -1
- package/lib/config.d.ts +202 -0
- package/lib/delegation.d.ts +123 -3
- package/lib/enforcement.d.ts +90 -1
- package/lib/errors.d.ts +168 -0
- package/lib/git-context.d.ts +17 -0
- package/lib/guard-log.d.ts +39 -0
- package/lib/handoff.d.ts +29 -0
- package/lib/hooks.d.ts +103 -0
- package/lib/identity.d.ts +61 -0
- package/lib/index.d.ts +32 -12
- package/lib/index.js +9819 -3858
- package/lib/job-log.d.ts +34 -0
- package/lib/jobs-runner.d.ts +105 -0
- package/lib/json-safe.d.ts +33 -0
- package/lib/lock.d.ts +42 -0
- package/lib/memory-feedback.d.ts +52 -0
- package/lib/memory-select.d.ts +78 -0
- package/lib/memory.d.ts +137 -0
- package/lib/model-inventory.d.ts +106 -0
- package/lib/phase-graph.d.ts +111 -0
- package/lib/phase-rules.d.ts +67 -8
- package/lib/plan-gate.d.ts +68 -0
- package/lib/policy-globs.d.ts +222 -0
- package/lib/policy-write.d.ts +42 -0
- package/lib/policy.d.ts +39 -0
- package/lib/recursive_ask.tool.d.ts +88 -0
- package/lib/recursive_closeout.tool.d.ts +1 -1
- package/lib/recursive_delegate.tool.d.ts +22 -0
- package/lib/recursive_preview.tool.d.ts +48 -0
- package/lib/recursive_review.tool.d.ts +28 -0
- package/lib/result-cap.d.ts +70 -0
- package/lib/review-round.d.ts +82 -0
- package/lib/review.d.ts +9 -0
- package/lib/role-route.d.ts +122 -0
- package/lib/router.d.ts +90 -5
- package/lib/runtime.d.ts +252 -12
- package/lib/settlement.d.ts +132 -0
- package/lib/skills-phase.d.ts +71 -0
- package/lib/status.d.ts +53 -1
- package/lib/teams-loop.d.ts +91 -2
- package/lib/training.d.ts +211 -0
- package/lib/ts-lint.d.ts +15 -0
- package/lib/types.d.ts +48 -0
- package/lib/workflow-audit.d.ts +207 -0
- package/package.json +29 -30
- package/preset/recursive/agent.cordis.yml +2 -2
- package/preset/recursive.patch.yml +312 -0
- package/scripts/e2e-run.mjs +51 -0
- package/scripts/link-dsh.mjs +233 -0
- package/scripts/live/fake-llm.mjs +150 -0
- package/scripts/live-session-plugin.mjs +179 -0
- package/scripts/live-session-stock.mjs +106 -0
- package/scripts/live-session.mjs +139 -0
- package/src/client/derive.ts +18 -2
- package/src/closeout-report.ts +274 -0
- package/src/closeout-standards.ts +102 -0
- package/src/closeout.ts +39 -2
- package/src/commands.ts +116 -4
- package/src/config.ts +113 -0
- package/src/delegation.ts +336 -18
- package/src/enforcement.ts +262 -72
- package/src/errors.ts +197 -0
- package/src/git-context.ts +33 -2
- package/src/guard-log.ts +134 -0
- package/src/handoff.ts +62 -0
- package/src/hooks.ts +316 -0
- package/src/identity.ts +230 -0
- package/src/index.ts +385 -20
- package/src/job-log.ts +112 -0
- package/src/jobs-runner.ts +222 -0
- package/src/json-safe.ts +75 -0
- package/src/lock.ts +153 -16
- package/src/memory-feedback.ts +185 -0
- package/src/memory-select.ts +187 -0
- package/src/memory.ts +309 -0
- package/src/model-inventory.ts +196 -0
- package/src/phase-graph.ts +191 -0
- package/src/phase-rules.ts +236 -0
- package/src/plan-gate.ts +111 -0
- package/src/policy-globs.ts +636 -0
- package/src/policy-write.ts +210 -0
- package/src/policy.ts +70 -5
- package/src/recursive_ask.tool.ts +276 -0
- package/src/recursive_audit_team.tool.ts +7 -3
- package/src/recursive_closeout.tool.ts +36 -35
- package/src/recursive_delegate.tool.ts +194 -0
- package/src/recursive_init.tool.ts +4 -3
- package/src/recursive_lint.tool.ts +81 -6
- package/src/recursive_lock.tool.ts +21 -4
- package/src/recursive_phase.tool.ts +3 -2
- package/src/recursive_preview.tool.ts +142 -0
- package/src/recursive_review.tool.ts +190 -0
- package/src/recursive_scratch.tool.ts +5 -4
- package/src/recursive_status.tool.ts +3 -2
- package/src/recursive_worktree.tool.ts +6 -5
- package/src/result-cap.ts +130 -0
- package/src/review-round.ts +335 -0
- package/src/review.ts +17 -3
- package/src/role-route.ts +230 -0
- package/src/router.ts +128 -2
- package/src/runtime.ts +968 -39
- package/src/settlement.ts +355 -0
- package/src/skills-phase.ts +143 -0
- package/src/snapshot.ts +39 -8
- package/src/status.ts +209 -4
- package/src/teams-loop.ts +223 -9
- package/src/training.ts +565 -0
- package/src/ts-lint.ts +38 -4
- package/src/types.ts +51 -0
- package/src/workflow-audit.ts +288 -0
- package/scripts/install-preset.cmd +0 -7
- package/scripts/install-preset.js +0 -101
package/src/runtime.ts
CHANGED
|
@@ -1,37 +1,55 @@
|
|
|
1
1
|
import { Service, type Context } from '@deepseek-ai/cordis'
|
|
2
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
3
|
-
import { join } from 'node:path'
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { dirname, join } from 'node:path'
|
|
4
4
|
import { lintRun } from './ts-lint.ts'
|
|
5
5
|
import { requirementsContent, worktreeContent, laterPhaseContent, detectGitContext, RUN_SCAFFOLD_DIRS, type GitContext } from './init-templates.ts'
|
|
6
|
-
import { foldRun, resolveRunDir } from './status.ts'
|
|
6
|
+
import { foldRun, getMdFieldValue, pendingWork, resolveRunDir } from './status.ts'
|
|
7
7
|
import {
|
|
8
8
|
getLockStatus,
|
|
9
|
+
PHASE_SEQUENCE,
|
|
9
10
|
getNextLegalPhase,
|
|
10
11
|
getPrerequisiteBlockers,
|
|
11
12
|
getStaleDownstreamPhases,
|
|
12
13
|
invalidateReceipt,
|
|
13
14
|
lockHashFromContent,
|
|
15
|
+
validateReceiptChain,
|
|
14
16
|
writeReceipt,
|
|
17
|
+
type ReceiptChainResult,
|
|
15
18
|
} from './lock.ts'
|
|
16
|
-
import type { RecursiveStatusResult } from './types.ts'
|
|
19
|
+
import type { PendingWorkItem, RecursiveStatusResult } from './types.ts'
|
|
20
|
+
import { findOperation, countOperations, operationId, recordOperation } from './identity.ts'
|
|
21
|
+
import { createHookRegistry, type HookRegistry } from './hooks.ts'
|
|
22
|
+
import { runTracked, abortReason, type JobsRegistryLike } from './jobs-runner.ts'
|
|
23
|
+
import { resolveSubagentTarget } from './role-route.ts'
|
|
24
|
+
import { checkModelChoice, describeInventory, type LlmInventoryLike } from './model-inventory.ts'
|
|
25
|
+
import { recordJobRun } from './job-log.ts'
|
|
26
|
+
import { toolError } from './errors.ts'
|
|
27
|
+
import { readGuardDecisions, type GuardDecisionRecord } from './guard-log.ts'
|
|
17
28
|
import { resolveControlPlaneRoot, type WorkspaceRegistryLike } from './workspace.ts'
|
|
18
29
|
import { phaseRulesFor, type PhaseRules } from './phase-rules.ts'
|
|
19
|
-
import {
|
|
30
|
+
import { closeoutReport, writeCloseoutReceipt } from './closeout-report.ts'
|
|
20
31
|
import { readScratch, writeScratch, appendScratch, type ScratchTarget } from './scratch.ts'
|
|
21
32
|
import { buildReviewBundle, type ReviewBundleInput } from './review.ts'
|
|
33
|
+
import { readMemoryEntries, retrieveMemory, renderMemorySection, selectMemory } from './memory.ts'
|
|
34
|
+
import { readFeedback, recordInjection, settleInjections } from './memory-feedback.ts'
|
|
35
|
+
import { runPhase8Trigger, resolveExtractor, spawnExtractorRunner } from './training.ts'
|
|
36
|
+
import { buildAskQuestion, GATE_DEFAULT_ARTIFACT, pendingGateFor } from './recursive_ask.tool.ts'
|
|
37
|
+
import { contractDigest } from './policy.ts'
|
|
38
|
+
import type { WorkflowEngineLike } from './workflow-audit.ts'
|
|
22
39
|
import { createHandoff, createChildBrief, replyPath, childScratchPath, buildDelegationPrompt, type HandoffInput, type ChildBriefInput } from './handoff.ts'
|
|
23
|
-
import { loadRouterPolicy, routerPolicyPath, resolveRole, capabilityProbe, delegationDecisionBasis, type RouterPolicy, type SubagentProviderLike, type RouteDecision, type CapabilityProbe } from './router.ts'
|
|
24
|
-
import { delegate, delegateContinuable, validateReferences, writeActionRecord, evaluateDelegationResult, reviewOutputSchema, defaultReviewToolFilter, type SubagentsRuntimeLike, type SubagentStartRequestLike, type SubagentResultLike, type Reference, type ActionRecordInput, type ContinuableDelegationLike, type SubagentParentHandle } from './delegation.ts'
|
|
40
|
+
import { loadRouterPolicy, routerPolicyPath, resolveRole, capabilityProbe, delegationDecisionBasis, type RouterPolicy, type RouterPolicyOverrides, type SubagentProviderLike, type RouteDecision, type CapabilityProbe } from './router.ts'
|
|
41
|
+
import { delegate, delegateContinuable, drainContinuableChildren, remainingDepthFor, validateReferences, referencesFromResult, writeActionRecord, evaluateDelegationResult, reviewOutputSchema, defaultReviewToolFilter, type SubagentsRuntimeLike, type SubagentStartRequestLike, type SubagentResultLike, type Reference, type ActionRecordInput, type ContinuableDelegationLike, type SubagentParentHandle } from './delegation.ts'
|
|
25
42
|
import { validateTransition, coupleGateBlockToGoal, type PhaseTransitionIntent, type RecursivePhaseState, type GateCheckResult } from './lifecycle.ts'
|
|
26
43
|
import { resolveEnforcementConfig, DEFAULT_ENFORCEMENT, evaluateToolGuard, detectTamper, type EnforcementConfig, type ToolGuardDecision, type ToolExecLike } from './enforcement.ts'
|
|
27
44
|
import type { Session } from '@deepseek-ai/dsh-session'
|
|
28
45
|
import { renderRecursivePolicy, type PolicyContext } from './policy.ts'
|
|
29
46
|
import { snapshotWorkspace } from './snapshot.ts'
|
|
30
47
|
import { createLinkedWorktree, promoteBranch, listWorktrees, defaultWorktreeBranch, type CreateWorktreeResult, type PromoteBranchResult } from './worktree.ts'
|
|
31
|
-
import { gitFacts } from './git-context.ts'
|
|
48
|
+
import { changedPaths, gitFacts } from './git-context.ts'
|
|
32
49
|
import { syncRunGoal, blockRunGoal, resumeRunGoal, type GoalServiceLike } from './goals-projection.ts'
|
|
33
50
|
import { auditToPass, renderTaskHistory, type TeamRuntimeLike, type AuditToPassResult, type TeamCallerHandle, type TeamTaskViewLike, type AuditRoundOutcome } from './teams-loop.ts'
|
|
34
51
|
import type { ContinuableChildId, ContinuableMessageId } from './delegation.ts'
|
|
52
|
+
import { runChildIds } from './settlement.ts'
|
|
35
53
|
|
|
36
54
|
declare module '@deepseek-ai/cordis' {
|
|
37
55
|
interface Context {
|
|
@@ -57,6 +75,54 @@ export interface LintArtifactResult {
|
|
|
57
75
|
passed: boolean
|
|
58
76
|
}
|
|
59
77
|
|
|
78
|
+
/**
|
|
79
|
+
* T15 (G): the folded status PLUS the rolling guard-decision evidence. Declared
|
|
80
|
+
* as an intersection rather than by editing RecursiveStatusResult/foldRun — the
|
|
81
|
+
* fold's own shape is parity-asserted and stays exactly as it was.
|
|
82
|
+
*/
|
|
83
|
+
export type RecursiveStatusWithGuardDecisions = RecursiveStatusResult & {
|
|
84
|
+
guardDecisions?: GuardDecisionRecord[]
|
|
85
|
+
/**
|
|
86
|
+
* T18: unresolved in-flight work, derived from the run directory on every call.
|
|
87
|
+
* Always present (empty when nothing is in flight) so consumers need no null
|
|
88
|
+
* check, and non-empty explains a `RM4403` lock refusal.
|
|
89
|
+
*/
|
|
90
|
+
pendingWork?: PendingWorkItem[]
|
|
91
|
+
/**
|
|
92
|
+
* T32: the receipt-chain verdict, derived read-only on every call. Always present
|
|
93
|
+
* so a caller can read `ok` without a null check; non-empty `breaks` names the
|
|
94
|
+
* first broken link. This is what makes a spliced or edited chain VISIBLE rather
|
|
95
|
+
* than merely detectable in a test.
|
|
96
|
+
*/
|
|
97
|
+
receiptChain?: ReceiptChainResult
|
|
98
|
+
/**
|
|
99
|
+
* T22: the local identifier of the policy section's STABLE prefix.
|
|
100
|
+
*
|
|
101
|
+
* Surfaced so "did the contract change under me?" is answerable from the status alone — the same
|
|
102
|
+
* digest the prompt carries, so a reader can compare them without re-rendering anything. It is an
|
|
103
|
+
* IDENTIFIER, not a cache directive: whether any provider caches the prefix is provider-side and
|
|
104
|
+
* unverified, which is why the item's "largest cost lever" label was withdrawn.
|
|
105
|
+
*/
|
|
106
|
+
contractDigest?: string
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** How many recent decisions to read from the log before scoping to one run. */
|
|
110
|
+
const GUARD_DECISION_READ_LIMIT = 200
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* T28: the delegating agent's own delegation depth, read structurally rather than by
|
|
114
|
+
* importing the harness's `delegationDepthOf`. The plugin already models every
|
|
115
|
+
* harness touchpoint as a minimal structural seam, and this keeps that convention —
|
|
116
|
+
* an absent depth means top level, which is the safe reading for a budget.
|
|
117
|
+
*/
|
|
118
|
+
function parentDepthOf(parent: unknown): number {
|
|
119
|
+
const depth = (parent as { options?: { subagentDepth?: unknown } } | null | undefined)?.options?.subagentDepth
|
|
120
|
+
return typeof depth === 'number' && Number.isSafeInteger(depth) && depth > 0 ? depth : 0
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** How many of that run's decisions the status surface carries. */
|
|
124
|
+
const GUARD_DECISION_SURFACE_LIMIT = 20
|
|
125
|
+
|
|
60
126
|
const ARTIFACT_STUB = {
|
|
61
127
|
'00-requirements.md': ['Run: ', 'Phase: 0', 'Status: DRAFT', 'Workflow version: recursive-mode-audit-v2', 'Inputs: none', 'Outputs: none', 'Scope note: '],
|
|
62
128
|
'00-worktree.md': ['Run: ', 'Phase: 0 (Worktree)', 'Status: DRAFT', 'Workflow version: recursive-mode-audit-v2', 'Inputs: none', 'Outputs: none', 'Scope note: '],
|
|
@@ -73,16 +139,199 @@ const ARTIFACT_STUB = {
|
|
|
73
139
|
export class RecursiveRuntime extends Service {
|
|
74
140
|
/** Recursive-mode runtime service. Owns run-state reads + lock/init/lint operations. */
|
|
75
141
|
|
|
76
|
-
constructor(ctx: Context, config: { repoRoot?: string; workspaceRegistry?: WorkspaceRegistryLike; goals?: GoalServiceLike | null } = {}) {
|
|
142
|
+
constructor(ctx: Context, config: { repoRoot?: string; workspaceRegistry?: WorkspaceRegistryLike; goals?: GoalServiceLike | null; jobs?: JobsRegistryLike | null; subagents?: SubagentsRuntimeLike | null; workflow?: WorkflowEngineLike | null } = {}) {
|
|
77
143
|
super(ctx, 'recursive')
|
|
78
144
|
this.repoRoot = config.repoRoot ?? process.cwd()
|
|
79
145
|
this.workspaceRegistry = config.workspaceRegistry ?? null
|
|
80
146
|
this.goalsService = config.goals ?? null
|
|
147
|
+
// T10: the native jobs registry is OPTIONAL. Absent, long operations run inline and say
|
|
148
|
+
// so; see `runTracked` for why that is better than refusing to work without a board.
|
|
149
|
+
this.jobs = config.jobs ?? null
|
|
150
|
+
// T39: the subagents seam, resolved at the COMPOSITION like the other optional services.
|
|
151
|
+
this.subagentsSeam = config.subagents ?? null
|
|
152
|
+
// T2: the workflow engine, likewise.
|
|
153
|
+
this.workflow = config.workflow ?? null
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** T10: the native jobs registry, when the composition mounts one. */
|
|
157
|
+
private readonly jobs: JobsRegistryLike | null
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* T23 — write a gate's answer into an artifact as a marker line.
|
|
161
|
+
*
|
|
162
|
+
* ⚠ REPLACED IN PLACE when the artifact already carries that gate's marker: two `TDD Mode:` lines
|
|
163
|
+
* would leave two answers to one question and make "what was decided?" depend on which a reader
|
|
164
|
+
* found first. The write is confined to the run directory, and an artifact that does not exist is
|
|
165
|
+
* CREATED — a decision recorded nowhere is not recorded.
|
|
166
|
+
*/
|
|
167
|
+
recordAskAnswer(root: string, runId: string, artifact: string, marker: string): { path: string; replaced: boolean } {
|
|
168
|
+
const dir = join(root, '.recursive', 'run', runId)
|
|
169
|
+
const path = join(dir, artifact)
|
|
170
|
+
const label = marker.slice(2).split(':')[0].trim()
|
|
171
|
+
let content = ''
|
|
172
|
+
try {
|
|
173
|
+
content = readFileSync(path, 'utf8')
|
|
174
|
+
} catch {
|
|
175
|
+
// A missing artifact is created below, so the decision still has somewhere to live.
|
|
176
|
+
}
|
|
177
|
+
const lines = content === '' ? [] : content.replace(/\n$/, '').split('\n')
|
|
178
|
+
const at = lines.findIndex((line) => line.startsWith('- ' + label + ':'))
|
|
179
|
+
const replaced = at >= 0
|
|
180
|
+
if (replaced) lines[at] = marker
|
|
181
|
+
else lines.push(marker)
|
|
182
|
+
mkdirSync(dir, { recursive: true })
|
|
183
|
+
writeFileSync(path, lines.join('\n') + '\n', 'utf8')
|
|
184
|
+
return { path, replaced }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* T2: the workflow engine, when the composition mounts one.
|
|
189
|
+
*
|
|
190
|
+
* OPTIONAL like every other seam here — without it an audit fan-out reports that it could not be
|
|
191
|
+
* orchestrated rather than pretending a fan-out happened. The engine's `workflow/*` events are
|
|
192
|
+
* observe-only, so this is used to START a run and await its result, never to drive one.
|
|
193
|
+
*/
|
|
194
|
+
private readonly workflow: WorkflowEngineLike | null
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* T39: the subagents seam the composition mounted, used when a caller does not pass one.
|
|
198
|
+
*
|
|
199
|
+
* ⚠ WHY THIS EXISTS, measured rather than assumed: `recursive_review.tool.ts` — the ONLY
|
|
200
|
+
* production caller of `delegateReview` — passes **no `subagents`** at its call site, and
|
|
201
|
+
* `delegateReview` reports *"no ctx.subagents runtime available (self-audit fallback)"* when
|
|
202
|
+
* the input lacks one. So on a composition that HAS the service, the review tool's rounds
|
|
203
|
+
* never reached a child at all, and the fallback message blamed a missing runtime that was
|
|
204
|
+
* in fact mounted. Resolving the seam here fixes the wiring without asking every call site to
|
|
205
|
+
* remember, while an explicit `input.subagents` still wins for a test or a narrower caller.
|
|
206
|
+
*/
|
|
207
|
+
private subagentsSeam: SubagentsRuntimeLike | null
|
|
208
|
+
/** FU-19: the host's provider/model inventory, or null when no llm service is mounted. */
|
|
209
|
+
private llmInventory: LlmInventoryLike | null = null
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* ⚠ FU-9 — ATTACH THE SEAM WHEN THE SERVICE APPEARS, not only when this plugin happens to apply.
|
|
213
|
+
*
|
|
214
|
+
* The composition resolved the seam with a ONE-SHOT `ctx.get('subagents')` at apply time, and a live run
|
|
215
|
+
* showed what that costs: the review fell back to self-audit, the action record said
|
|
216
|
+
* `Execution Mode: self-audit (continuable)` and `Status: failed`, and **no child was ever started** — while
|
|
217
|
+
* the child DIRECTORY existed all along, because the plugin writes its own brief before calling any service.
|
|
218
|
+
* I read the directory and built a host-limitation story on top of it; the record said otherwise.
|
|
219
|
+
*
|
|
220
|
+
* If the subagents service is mounted by a later loader layer, a one-shot get returns undefined and nothing
|
|
221
|
+
* re-resolves it. `ctx.inject(['subagents'], …)` is the harness's own pattern for exactly this, and calling
|
|
222
|
+
* this method from there makes the seam arrive whenever it arrives. Idempotent: the last attach wins, which
|
|
223
|
+
* is what a re-apply after a reload wants.
|
|
224
|
+
*/
|
|
225
|
+
attachSubagents(seam: SubagentsRuntimeLike | null): void {
|
|
226
|
+
this.subagentsSeam = seam
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* ⚠ FU-19 — THE LLM INVENTORY SEAM, resolved the same late-attaching way the subagents seam is and for the same
|
|
231
|
+
* measured reason: a one-shot `ctx.get` at apply time misses a service mounted by a later layer.
|
|
232
|
+
*
|
|
233
|
+
* Null is a legitimate value and it is NOT treated as "no models exist" — `describeInventory(null)` reports a
|
|
234
|
+
* named unavailability, and `checkModelChoice` turns that into the `unverified` verdict. A missing inventory
|
|
235
|
+
* therefore never silently approves a model and never silently replaces one.
|
|
236
|
+
*/
|
|
237
|
+
attachLlmInventory(seam: LlmInventoryLike | null): void {
|
|
238
|
+
this.llmInventory = seam
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** What the composition attached, for a caller that needs to report or assert it. */
|
|
242
|
+
attachedSubagents(): SubagentsRuntimeLike | null {
|
|
243
|
+
return this.subagentsSeam
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* ⚠ FU-9 — THE ROUTER'S PROVIDER MAP, BUILT FROM THE SEAM THAT IS ALREADY ATTACHED.
|
|
248
|
+
*
|
|
249
|
+
* `router.ts` returns the NATIVE tier for the first of `[role, 'spawn', 'fork', 'dsh-sdk']` present in this
|
|
250
|
+
* map. Handed `{}` it tried the external CLI route (null in the default policy) and fell to the policy
|
|
251
|
+
* fallback — self-audit — with a message naming neither. The router already preferred native; nobody ever
|
|
252
|
+
* gave it a name. A live review self-audited for five rounds because of it.
|
|
253
|
+
*
|
|
254
|
+
* `SubagentProviderLike` is only a DESCRIPTOR (`{ name, capabilities? }`), so a provider is registered by
|
|
255
|
+
* ASKING the service for it rather than by wrapping it. A service that cannot enumerate yields an empty map
|
|
256
|
+
* and the previous behaviour, which is the correct degradation rather than a guess about the shape.
|
|
257
|
+
*/
|
|
258
|
+
private providerMapFromSeam(): Record<string, SubagentProviderLike> {
|
|
259
|
+
const seam = this.subagentsSeam
|
|
260
|
+
this.lastProviderNames = []
|
|
261
|
+
if (seam === null) return {}
|
|
262
|
+
const map: Record<string, SubagentProviderLike> = {}
|
|
263
|
+
// ⚠ ASK THE SERVICE WHAT IT HAS, FIRST. The previous version only probed three invented names and
|
|
264
|
+
// registered whatever came back — and the live record then said `provider spawn`, so the router faithfully
|
|
265
|
+
// returned a name the host does not serve and `start('spawn', …)` produced NOTHING. A name this plugin made
|
|
266
|
+
// up is not a provider the host knows, and the difference is a child that runs versus a silent no-op.
|
|
267
|
+
try {
|
|
268
|
+
const listed = seam.list?.()
|
|
269
|
+
if (Array.isArray(listed)) {
|
|
270
|
+
for (const entry of listed) {
|
|
271
|
+
if (typeof entry === 'string' && entry !== '') map[entry] = { name: entry }
|
|
272
|
+
else if (entry !== null && typeof entry === 'object' && typeof (entry as { name?: unknown }).name === 'string') {
|
|
273
|
+
const named = entry as { name: string } & SubagentProviderLike
|
|
274
|
+
map[named.name] = named
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
} catch {
|
|
279
|
+
// A service that cannot enumerate is not an error: the probes below are the fallback, not the plan.
|
|
280
|
+
}
|
|
281
|
+
// The probes stay as a FALLBACK for a service that exposes getProvider but no list. Whatever they return is
|
|
282
|
+
// registered under the name ASKED FOR, which is only sound because the name came from a real lookup.
|
|
283
|
+
for (const name of ['spawn', 'fork', 'dsh-sdk']) {
|
|
284
|
+
if (map[name] !== undefined) continue
|
|
285
|
+
try {
|
|
286
|
+
const found = seam.getProvider?.(name)
|
|
287
|
+
if (found !== undefined && found !== null) map[name] = found as SubagentProviderLike
|
|
288
|
+
} catch {
|
|
289
|
+
// An unavailable name is not an error: the next candidate still gets its turn.
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
this.lastProviderNames = Object.keys(map)
|
|
293
|
+
return map
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** The provider names the last `providerMapFromSeam` call registered, for the record and for assertions. */
|
|
297
|
+
private lastProviderNames: string[] = []
|
|
298
|
+
|
|
299
|
+
/** What the router could choose from, so a failure can say whether the name it used was ever on offer. */
|
|
300
|
+
knownProviderNames(): string[] {
|
|
301
|
+
return this.lastProviderNames
|
|
81
302
|
}
|
|
82
303
|
|
|
83
304
|
private readonly repoRoot: string
|
|
84
305
|
private readonly workspaceRegistry: WorkspaceRegistryLike | null
|
|
85
306
|
private readonly goalsService: GoalServiceLike | null
|
|
307
|
+
/**
|
|
308
|
+
* T27 — the hook registry, EXPOSED so a sibling plugin can participate in a run
|
|
309
|
+
* without patching this one:
|
|
310
|
+
*
|
|
311
|
+
* ctx.recursive.hooks.register('pre_trigger', { name: 'my-check', priority: 10, run })
|
|
312
|
+
*
|
|
313
|
+
* That is the whole point of the item: the plugin's own enforcement will be
|
|
314
|
+
* re-expressed as built-in hooks on this same registry, so a sibling and a built-in
|
|
315
|
+
* are peers — same ordering rules, same failure policy, same audit trail — rather
|
|
316
|
+
* than one being privileged code and the other a guest.
|
|
317
|
+
*
|
|
318
|
+
* Public and created eagerly: a registry that has to be "got" before it can be used
|
|
319
|
+
* is a registry whose ordering depends on when someone remembered to fetch it.
|
|
320
|
+
*/
|
|
321
|
+
readonly hooks: HookRegistry = createHookRegistry()
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* T7 — router overrides from the settings namespace. Kept beside the config rather than
|
|
325
|
+
* merged into the file so the workspace's `recursive-router.json` stays the declarative
|
|
326
|
+
* source: `loadRouterPolicy` reads the file and lays these on top.
|
|
327
|
+
*/
|
|
328
|
+
private _routerOverrides: RouterPolicyOverrides | undefined = undefined
|
|
329
|
+
|
|
330
|
+
/** T7: set (or clear) the router overrides. Called from `apply` on every plugin load. */
|
|
331
|
+
setRouterOverrides(overrides: RouterPolicyOverrides | undefined): void {
|
|
332
|
+
this._routerOverrides = overrides
|
|
333
|
+
}
|
|
334
|
+
|
|
86
335
|
private _enforcementConfig: EnforcementConfig | null = null
|
|
87
336
|
|
|
88
337
|
/**
|
|
@@ -166,18 +415,85 @@ export class RecursiveRuntime extends Service {
|
|
|
166
415
|
* Run-scoped closeout receipt scaffold (R2), rooted under the given
|
|
167
416
|
* workspace root. Refuses runIds outside the root (never crosses workspaces).
|
|
168
417
|
*/
|
|
169
|
-
closeoutRun(root: string, runId: string, phase: string) {
|
|
418
|
+
async closeoutRun(root: string, runId: string, phase: string, agent?: { session?: { header?: { cwd?: string } } } | null) {
|
|
170
419
|
const runDir = join(root, '.recursive', 'run', runId)
|
|
171
420
|
const runRoot = join(root, '.recursive', 'run')
|
|
172
421
|
// workspace-scoping guard: the run must be under this root
|
|
173
422
|
if (!runDir.startsWith(runRoot) || !existsSync(runDir)) {
|
|
174
423
|
return { error: 'Run not found in current workspace: ' + runId }
|
|
175
424
|
}
|
|
425
|
+
// Declared OUTSIDE the try so the failure path can report it too: a refused closeout still tells the
|
|
426
|
+
// caller whether its children were released.
|
|
427
|
+
let drain: { children: number; drained: boolean; reason?: string } | null = null
|
|
176
428
|
try {
|
|
177
|
-
|
|
178
|
-
|
|
429
|
+
// T30 — THE RUN-CLOSE TRIGGER, at the RE-RUN of closeout phase 08 and nowhere else.
|
|
430
|
+
//
|
|
431
|
+
// ⚠ DETECTED FROM THE RECEIPT THAT ALREADY EXISTS, not from a counter kept beside it: if phase 08
|
|
432
|
+
// has a receipt BEFORE this closeout runs, then it has been closed out before and this is the
|
|
433
|
+
// re-run the parent asks for. A first lock would otherwise train the run on itself.
|
|
434
|
+
//
|
|
435
|
+
// ⚠ THE SEAMS ARE THE REAL FILESYSTEM HERE — this IS the production call site, so the write and
|
|
436
|
+
// read seams that the tests inject are bound to the actual run root, and the registry read goes
|
|
437
|
+
// to `memory/MEMORY.md` under the same root.
|
|
438
|
+
const isPhase8 = phase.startsWith('08') || phase === '08-memory-impact.md'
|
|
439
|
+
const rerun = isPhase8 && existsSync(join(runDir, 'locks', '08-memory-impact.receipt.json'))
|
|
440
|
+
// ⚠ FU-3 — THE DRAIN IS COMPUTED **BEFORE** THE STUB WRITE, and the first version had it after:
|
|
441
|
+
// `closeoutPhase` now THROWS when the artifact is already LOCKED (the FU-8 guard), so a drain placed
|
|
442
|
+
// after that call never ran on exactly the runs that reach closeout twice — the ones that HAVE
|
|
443
|
+
// children to release. Draining is a run-close action and does not depend on scaffolding a stub.
|
|
444
|
+
const children = isPhase8 ? runChildIds(runDir) : []
|
|
445
|
+
drain = isPhase8
|
|
446
|
+
? this.subagentsSeam === null
|
|
447
|
+
? { children: children.length, drained: false, reason: 'no subagents runtime is mounted, so the children could not be drained' }
|
|
448
|
+
: await drainContinuableChildren(this.subagentsSeam, agent as never, children)
|
|
449
|
+
.then(() => ({ children: children.length, drained: true }))
|
|
450
|
+
.catch((err: unknown) => ({ children: children.length, drained: false, reason: err instanceof Error ? err.message : String(err) }))
|
|
451
|
+
: null
|
|
452
|
+
// ⚠ FU-12 — THE CLOSEOUT NO LONGER WRITES TO THE PHASE DOCUMENT.
|
|
453
|
+
// It READS the artifact, reports what the standard requires and what is missing, and records that
|
|
454
|
+
// examination as a receipt of its own under locks/<stem>.closeout.receipt.json — never over the doc.
|
|
455
|
+
const result = closeoutReport(runDir, phase)
|
|
456
|
+
// P3b: settle this run's injections against its own outcome. Only phases that LOCKED are evidence, and
|
|
457
|
+
// a run settles once, at closeout.
|
|
458
|
+
settleInjections(root, runDir, PHASE_SEQUENCE.filter((file) => getLockStatus(join(runDir, file)) === 'LOCKED'))
|
|
459
|
+
writeCloseoutReceipt(runDir, phase)
|
|
460
|
+
const training = isPhase8
|
|
461
|
+
? runPhase8Trigger(root, runId, {
|
|
462
|
+
rerun,
|
|
463
|
+
// The extractor is resolved but NOT spawned here: this plugin never embeds one, and an
|
|
464
|
+
// unset command must surface as exit 2 rather than as a silent success.
|
|
465
|
+
extractorAvailable: resolveExtractor(process.env) !== null,
|
|
466
|
+
// FU-5: the PRODUCTION runner — spawn the command with `stdio: 'ignore'` and read the file it
|
|
467
|
+
// was asked to write. `stdio: 'ignore'` is deliberate: this sandbox denies a child the piped
|
|
468
|
+
// stdio a capture needs, and the response file is the parent's own interface anyway.
|
|
469
|
+
runner: spawnExtractorRunner({ cwd: root, responseFile: join(runDir, 'training-response.json') }),
|
|
470
|
+
write: (relativePath, content) => {
|
|
471
|
+
const target = join(root, relativePath)
|
|
472
|
+
mkdirSync(dirname(target), { recursive: true })
|
|
473
|
+
writeFileSync(target, content, 'utf8')
|
|
474
|
+
return relativePath
|
|
475
|
+
},
|
|
476
|
+
readText: (relativePath) => {
|
|
477
|
+
try {
|
|
478
|
+
return readFileSync(join(root, relativePath), 'utf8')
|
|
479
|
+
} catch {
|
|
480
|
+
return null
|
|
481
|
+
}
|
|
482
|
+
},
|
|
483
|
+
})
|
|
484
|
+
: null
|
|
485
|
+
// ⚠ THE SECOND drain ASSIGNMENT WAS REMOVED (FU-12): it re-drained on the success path and
|
|
486
|
+
// overwrote the value the throw path depends on. The assignment above is the one that matters.
|
|
487
|
+
|
|
488
|
+
// ⚠ THE DRAIN HAPPENS BEFORE THE STUB WRITE, and the first version got this wrong: the FU-8 guard
|
|
489
|
+
// THROWS when 08 is already LOCKED, so a drain placed after it never ran on exactly the runs that
|
|
490
|
+
// reach closeout twice — leaking every child of a completed run. Draining is a RUN-CLOSE action and
|
|
491
|
+
// does not depend on scaffolding a stub, so it happens first and is reported on BOTH paths.
|
|
492
|
+
return { closeoutPhase: phase, runId, ...result, ...(training === null ? {} : { training }), ...(drain === null ? {} : { drain }) }
|
|
179
493
|
} catch (err) {
|
|
180
|
-
|
|
494
|
+
// The drain result is carried into the failure path too: a refused closeout still tells the caller
|
|
495
|
+
// whether its children were released.
|
|
496
|
+
return { error: (err as Error).message, ...(typeof drain !== 'undefined' && drain !== null ? { drain } : {}) }
|
|
181
497
|
}
|
|
182
498
|
}
|
|
183
499
|
|
|
@@ -214,6 +530,19 @@ export class RecursiveRuntime extends Service {
|
|
|
214
530
|
* ctx.subagents with the full request (R4). Workspace-scoped: every path
|
|
215
531
|
* resolves under the session's control-plane root.
|
|
216
532
|
*
|
|
533
|
+
* DELEGATION IS ALWAYS CONTINUABLE (T35). The default mode is `continuable`:
|
|
534
|
+
* an explicit `mode: 'one-shot'` is the ONLY way to give up the repair path,
|
|
535
|
+
* and that path only exists for a caller that genuinely discards the result.
|
|
536
|
+
*
|
|
537
|
+
* WHY THIS IS THE DEFAULT. A one-shot child is NOT resumable — the harness
|
|
538
|
+
* rejects a resume with "subagent cannot be resumed" — so choosing one-shot
|
|
539
|
+
* forfeits the ability to send a failed review back to the agent that did the
|
|
540
|
+
* work. A continuable child has ONE durable Session across activations, so a
|
|
541
|
+
* REVISE reaches the SAME child with its working context intact instead of
|
|
542
|
+
* spawning a fresh one that must re-read the whole handoff to rediscover what
|
|
543
|
+
* it already knew. Since verification that cannot be followed by repair is
|
|
544
|
+
* just a complaint, the repair path is the default rather than an option.
|
|
545
|
+
*
|
|
217
546
|
* `mode: 'continuable'` (T4) runs the audit→repair→re-audit loop on ONE
|
|
218
547
|
* durable continuable child (startContinuable → followup with the repair
|
|
219
548
|
* instruction → settle) and drains the child on closeout. It requires an
|
|
@@ -241,18 +570,98 @@ export class RecursiveRuntime extends Service {
|
|
|
241
570
|
subagents?: SubagentsRuntimeLike
|
|
242
571
|
maxDepth?: number
|
|
243
572
|
toolFilter?: unknown
|
|
573
|
+
/**
|
|
574
|
+
* ⚠ FU-17 — THE BRIEF SLICE, WHEN THE CALLER OWNS IT.
|
|
575
|
+
*
|
|
576
|
+
* A review's slice is written here because a reviewer's briefing is review-shaped by definition. A WORK
|
|
577
|
+
* delegation needs the opposite kind of briefing — what to produce and the standard it will be linted
|
|
578
|
+
* against — and that is computed by `buildWorkSlice` from the phase rules. This seam lets a work caller pass
|
|
579
|
+
* it in without this method growing a second, drifting copy of the phase standard.
|
|
580
|
+
*
|
|
581
|
+
* ADDITIVE BY CONSTRUCTION: absent, the review slice below is built exactly as it always was, which is what
|
|
582
|
+
* keeps the review path's behaviour provable rather than merely claimed.
|
|
583
|
+
*/
|
|
584
|
+
slice?: string
|
|
585
|
+
/**
|
|
586
|
+
* ⚠ FU-17 — whether this delegation is WORK or a REVIEW. It changes two things and nothing else: the slice
|
|
587
|
+
* (when `slice` is passed) and the `Purpose` line of the action record, so a reader of the run can tell a
|
|
588
|
+
* child that produced something from a child that judged something.
|
|
589
|
+
*/
|
|
590
|
+
kind?: 'review' | 'work'
|
|
591
|
+
/**
|
|
592
|
+
* T35: which child lifecycle to use. DEFAULT `continuable` — a one-shot
|
|
593
|
+
* child cannot be resumed, so one-shot forfeits the repair path and must be
|
|
594
|
+
* requested explicitly by a caller that will discard the result.
|
|
595
|
+
*/
|
|
596
|
+
/**
|
|
597
|
+
* ⚠ FU-18 — IS THIS ROUND A CONTINUATION OF AN OPEN DELEGATION RATHER THAN A FRESH ONE?
|
|
598
|
+
*
|
|
599
|
+
* Set by a caller that is resuming the same child to deliver FEEDBACK. It is the difference between "run this
|
|
600
|
+
* operation again", which the T19 guard exists to refuse, and "carry on with the operation that is already
|
|
601
|
+
* open", which the guard must not refuse — see the guard below for why the obvious alternative is worse.
|
|
602
|
+
*/
|
|
603
|
+
continuing?: boolean
|
|
604
|
+
/**
|
|
605
|
+
* ⚠ FU-19 — A PER-CALL CHOICE, which is what "change them on demand" means. Both win over every configured
|
|
606
|
+
* level, and both are optional: absent means "resolve the ladder", NOT "clear".
|
|
607
|
+
*/
|
|
608
|
+
providerOverride?: string | null
|
|
609
|
+
modelOverride?: string | null
|
|
244
610
|
mode?: 'one-shot' | 'continuable'
|
|
245
611
|
awaitRoundResult?: (childId: ContinuableChildId, messageId: ContinuableMessageId) => Promise<SubagentResultLike | null>
|
|
612
|
+
/**
|
|
613
|
+
* T39: interrupt the LIVE child when this delegation's job is killed — the one thing T10's
|
|
614
|
+
* synchronous call sites cannot do, and the reason a delegation's kill can be genuinely
|
|
615
|
+
* pre-emptive. Optional: without it a kill still parks the round and cannot reach the
|
|
616
|
+
* child, which is stated rather than implied.
|
|
617
|
+
*/
|
|
618
|
+
interrupt?: (childId: string, reason: string) => void
|
|
246
619
|
maxRounds?: number
|
|
247
620
|
/** T4: the exact live direct-parent Agent (object-identity authority). */
|
|
248
621
|
parent?: SubagentParentHandle
|
|
249
622
|
}) {
|
|
250
|
-
const policy = loadRouterPolicy(input.policyPath ?? routerPolicyPath(input.root))
|
|
251
|
-
|
|
623
|
+
const policy = loadRouterPolicy(input.policyPath ?? routerPolicyPath(input.root), this._routerOverrides)
|
|
624
|
+
// ⚠ FU-9 — THE ROUTER GETS THE SAME SEAM FALLBACK THE DELEGATION ONE LINE BELOW ALREADY HAD, and not
|
|
625
|
+
// getting it is why a live review self-audited for five rounds of investigation. `resolveRole` tries
|
|
626
|
+
// `[role, 'spawn', 'fork', 'dsh-sdk']` against this map and returns the NATIVE tier for the first name it
|
|
627
|
+
// finds; handed `{}` it fell through to an external CLI the policy leaves null and then to the policy
|
|
628
|
+
// fallback, and its message named none of that. The router already preferred native — it was never given a
|
|
629
|
+
// name to prefer. `SubagentProviderLike` is only a descriptor (`{ name, capabilities? }`), so a provider is
|
|
630
|
+
// registered by ASKING the service for it; a service that cannot enumerate yields an empty map and the old
|
|
631
|
+
// behaviour, which is the correct degradation rather than a guess.
|
|
632
|
+
const providers = input.providers ?? this.providerMapFromSeam()
|
|
633
|
+
// T39: the seam the caller passed, else the one the COMPOSITION mounted. See the field's
|
|
634
|
+
// comment: without this the review tool's rounds silently self-audited on a host that had
|
|
635
|
+
// the service all along.
|
|
636
|
+
const subagents: SubagentsRuntimeLike | undefined = input.subagents ?? this.subagentsSeam ?? undefined
|
|
252
637
|
const decision = resolveRole(input.role, policy, providers)
|
|
253
638
|
const probe = capabilityProbe({ providers, role: input.role, policy })
|
|
254
639
|
|
|
255
640
|
// R1 bundle + R2 handoff/brief/prompt (file-backed context-in contract).
|
|
641
|
+
// T14 — PRIOR-RUN MEMORY, retrieved by relevance to THIS phase/artifact/role and written into
|
|
642
|
+
// the bundle. Two facts decided the shape: there is NO native memory service (measured), so the
|
|
643
|
+
// store is the plugin's own `.recursive/memory/` layer; and the prompt references the bundle by
|
|
644
|
+
// PATH, so the content must go INTO the bundle rather than beside the prompt.
|
|
645
|
+
//
|
|
646
|
+
// `memoryRefs` is filled as well as the content, and it is NOT a duplicate: it was a DEAD SLOT —
|
|
647
|
+
// rendered by the bundle and set by nobody — so the memory section a reviewer was meant to see
|
|
648
|
+
// never existed. Refs give traceability; the content is what a reviewer can actually cite.
|
|
649
|
+
const memoryEntries = retrieveMemory(
|
|
650
|
+
readMemoryEntries(
|
|
651
|
+
(path) => { try { return readFileSync(path, 'utf8') } catch { return null } },
|
|
652
|
+
(kind) => {
|
|
653
|
+
try {
|
|
654
|
+
return readdirSync(join(input.root, '.recursive', 'memory', kind))
|
|
655
|
+
.filter((name) => name.endsWith('.md'))
|
|
656
|
+
.map((name) => join(input.root, '.recursive', 'memory', kind, name))
|
|
657
|
+
} catch {
|
|
658
|
+
// An absent or unreadable memory directory is a missing advantage, not a failed review.
|
|
659
|
+
return []
|
|
660
|
+
}
|
|
661
|
+
},
|
|
662
|
+
),
|
|
663
|
+
[input.phase, input.role, ...input.auditQuestions].join(' '),
|
|
664
|
+
)
|
|
256
665
|
const bundle = buildReviewBundle({
|
|
257
666
|
root: input.root,
|
|
258
667
|
runId: input.runId,
|
|
@@ -265,6 +674,10 @@ export class RecursiveRuntime extends Service {
|
|
|
265
674
|
codeRefs: input.codeRefs,
|
|
266
675
|
changedFiles: input.changedFiles,
|
|
267
676
|
diffBasis: input.diffBasis,
|
|
677
|
+
...(memoryEntries.length === 0 ? {} : {
|
|
678
|
+
memory: renderMemorySection(memoryEntries),
|
|
679
|
+
memoryRefs: [...new Set(memoryEntries.map((entry) => entry.source))],
|
|
680
|
+
}),
|
|
268
681
|
})
|
|
269
682
|
const handoffPath = createHandoff({
|
|
270
683
|
root: input.root,
|
|
@@ -288,7 +701,9 @@ export class RecursiveRuntime extends Service {
|
|
|
288
701
|
runId: input.runId,
|
|
289
702
|
delegationId: input.delegationId,
|
|
290
703
|
childId: input.childId,
|
|
291
|
-
|
|
704
|
+
// ⚠ FU-17 — `input.slice` wins when a work caller supplies one; otherwise this is byte-for-byte the review
|
|
705
|
+
// slice it has always been. A work brief cannot be built here without a second copy of the phase standard.
|
|
706
|
+
slice: input.slice ?? 'Perform the delegated ' + input.role + ' for run ' + input.runId + ' (' + input.phase + ') and write your submission to reply.md.',
|
|
292
707
|
})
|
|
293
708
|
const prompt = buildDelegationPrompt({
|
|
294
709
|
root: input.root,
|
|
@@ -302,36 +717,274 @@ export class RecursiveRuntime extends Service {
|
|
|
302
717
|
// R4: plugin-driven delegation with the full request shape. `parent` is the
|
|
303
718
|
// exact live direct-parent Agent (object-identity authority in the live
|
|
304
719
|
// subagent service); absent it, the live start() rejects the request.
|
|
720
|
+
// T28: the depth budget replaces the hardcoded `?? 2`. The parent's own depth is
|
|
721
|
+
// read structurally (the plugin's seam style) and SUBTRACTED, so the configured
|
|
722
|
+
// ceiling bounds the whole recursion rather than being re-granted at every level
|
|
723
|
+
// — a "depth 2" budget that resets per level bounds nothing.
|
|
724
|
+
// T9: notes about routing decisions that could NOT be applied, so a caller is told rather
|
|
725
|
+
// than left to infer it from a model that silently did not take effect.
|
|
726
|
+
const routingNotes: string[] = []
|
|
305
727
|
const request: SubagentStartRequestLike = {
|
|
306
728
|
prompt: [{ type: 'text', text: prompt }],
|
|
307
729
|
label: input.delegationId + '/' + input.childId,
|
|
308
730
|
outputSchema: reviewOutputSchema(),
|
|
309
731
|
toolFilter: input.toolFilter ?? defaultReviewToolFilter(),
|
|
310
|
-
maxDepth: input.maxDepth
|
|
732
|
+
maxDepth: remainingDepthFor(this.enforcementConfig.budgets, parentDepthOf(input.parent), input.maxDepth),
|
|
311
733
|
}
|
|
312
734
|
if (input.parent !== undefined) request.parent = input.parent
|
|
313
735
|
|
|
736
|
+
// ⚠ FU-19 — THE PROVIDER/MODEL LADDER, APPLIED WHERE THE PROVIDER SAYS IT CAN BE.
|
|
737
|
+
//
|
|
738
|
+
// The ladder (per-call → phase → role → general default) is resolved by `resolveSubagentTarget`, which also
|
|
739
|
+
// reports WHICH LEVEL chose each value — so "why did this child run on that model" is answerable from the
|
|
740
|
+
// decision rather than by reading this code. `modelForRole` was the single-level version of this and is gone.
|
|
741
|
+
//
|
|
742
|
+
// ⚠ THE CAPABILITY GATE STAYS AUTHORITATIVE, unchanged: the harness REJECTS a start that sends `agentOptions`
|
|
743
|
+
// to a provider without the `agentOptions` capability, so gating on the model alone would BREAK delegations on
|
|
744
|
+
// providers that do not accept overrides — a routing feature that takes down the delegation it was meant to
|
|
745
|
+
// improve. A choice that cannot be honoured is REPORTED, never silently dropped.
|
|
746
|
+
const target = resolveSubagentTarget({
|
|
747
|
+
role: input.role,
|
|
748
|
+
phase: input.phase,
|
|
749
|
+
policy,
|
|
750
|
+
...(input.providerOverride === undefined && input.modelOverride === undefined
|
|
751
|
+
? {}
|
|
752
|
+
: { override: { provider: input.providerOverride ?? null, model: input.modelOverride ?? null } }),
|
|
753
|
+
ladderProvider: decision.provider ?? null,
|
|
754
|
+
})
|
|
755
|
+
if (target.provider !== null && target.provider !== decision.provider) {
|
|
756
|
+
// The user named a provider. The router still says WHICH TIER it resolved, but the name used to create the
|
|
757
|
+
// child is the user's — and the note states both rather than leaving two answers in the record.
|
|
758
|
+
routingNotes.push(
|
|
759
|
+
'provider ' + target.provider + ' chosen from ' + target.chosen.provider
|
|
760
|
+
+ ' (the router tier resolved as ' + decision.tier + ')',
|
|
761
|
+
)
|
|
762
|
+
}
|
|
763
|
+
if (target.model !== null) {
|
|
764
|
+
const chosenProvider = target.provider ?? decision.provider ?? ''
|
|
765
|
+
const capable = providers[chosenProvider]?.capabilities?.agentOptions === true
|
|
766
|
+
// ⚠ FU-19 — THE CHOICE IS CHECKED AGAINST WHAT DSH ACTUALLY HAS, and the three-state verdict decides what
|
|
767
|
+
// happens next. `missing` means the inventory ANSWERED and does not have this model: it is NOT applied, and
|
|
768
|
+
// crucially NO OTHER MODEL IS SUBSTITUTED — the child inherits the session default and the note says so.
|
|
769
|
+
// `unverified` (no inventory to ask) still applies the model, because refusing on the strength of a service
|
|
770
|
+
// that merely was not mounted would refuse something the user asked for on no evidence.
|
|
771
|
+
const check = checkModelChoice(await describeInventory(this.llmInventory), target.model)
|
|
772
|
+
if (check.verdict === 'missing') {
|
|
773
|
+
routingNotes.push(
|
|
774
|
+
'model ' + target.model + ' was chosen (from ' + target.chosen.model + '), but ' + check.reason
|
|
775
|
+
+ ' — it was NOT applied and no other model was substituted, so the child inherits the session default',
|
|
776
|
+
)
|
|
777
|
+
} else if (!capable) {
|
|
778
|
+
routingNotes.push(
|
|
779
|
+
'model ' + target.model + ' was chosen (from ' + target.chosen.model + '), but provider '
|
|
780
|
+
+ (chosenProvider || '(none)') + ' does not declare the agentOptions capability, so the model was NOT applied',
|
|
781
|
+
)
|
|
782
|
+
} else {
|
|
783
|
+
request.agentOptions = { model: target.model }
|
|
784
|
+
routingNotes.push('model ' + target.model + ' applied (from ' + target.chosen.model + '); inventory says: ' + check.reason)
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// T19 — DETERMINISTIC OPERATION IDENTITY for the delegation itself. The id
|
|
789
|
+
// covers the inputs PLUS the artifact's BODY, and the body is what keeps a
|
|
790
|
+
// re-review of a REPAIRED artifact a genuinely different operation rather than a
|
|
791
|
+
// recognised repeat. `childId` is deliberately NOT part of it: a fresh round
|
|
792
|
+
// allocates one at random, so including it would make the id meaningless.
|
|
793
|
+
//
|
|
794
|
+
// The body — not the `LockHash` — for the reason T37 found the hard way: a lock
|
|
795
|
+
// hash covers the wall-clock `LockedAt`, so it changes between two locks of
|
|
796
|
+
// byte-identical content and would make this identity fire only half the time.
|
|
797
|
+
const artifactBody = (() => {
|
|
798
|
+
try {
|
|
799
|
+
return lockHashFromContent(
|
|
800
|
+
readFileSync(input.artifactPath, 'utf8').replace(/^[ \t]*Status:.*$/m, '').replace(/^[ \t]*LockedAt:.*\n?/m, ''),
|
|
801
|
+
)
|
|
802
|
+
} catch {
|
|
803
|
+
return 'absent'
|
|
804
|
+
}
|
|
805
|
+
})()
|
|
806
|
+
const operation = operationId({
|
|
807
|
+
act: 'delegate-review',
|
|
808
|
+
input: {
|
|
809
|
+
runId: input.runId,
|
|
810
|
+
phase: input.phase,
|
|
811
|
+
role: input.role,
|
|
812
|
+
delegationId: input.delegationId,
|
|
813
|
+
body: artifactBody,
|
|
814
|
+
},
|
|
815
|
+
})
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* T39 — the PRODUCTION provider of the interrupt seam.
|
|
819
|
+
*
|
|
820
|
+
* The delegation's job kill must reach the LIVE child, and the child is interrupted
|
|
821
|
+
* through the SAME `subagents.interrupt` seam the continuable lifecycle already uses —
|
|
822
|
+
* never a second stop path, so a board kill and a lifecycle stop cannot drift apart. The
|
|
823
|
+
* authority is `{ kind: 'ancestor', agent: parent }`: the parent Agent is the object-
|
|
824
|
+
* identity authority the seam expects, exactly as `followup` uses it.
|
|
825
|
+
*
|
|
826
|
+
* An explicit `input.interrupt` still WINS, so a caller with a better authority (a user-
|
|
827
|
+
* initiated stop, say) can supply one. With no seam and no parent there is nothing to
|
|
828
|
+
* interrupt, and this quietly does nothing rather than throwing: the kill already parked
|
|
829
|
+
* the round, and a kill that cannot reach a child must not become an error in its place.
|
|
830
|
+
*/
|
|
831
|
+
const interruptChild = input.interrupt ?? ((childId: string, reason: string) => {
|
|
832
|
+
const seam = subagents
|
|
833
|
+
if (seam?.interrupt === undefined || input.parent === undefined) return
|
|
834
|
+
try {
|
|
835
|
+
seam.interrupt(childId as ContinuableChildId, { kind: 'ancestor', agent: input.parent })
|
|
836
|
+
} catch {
|
|
837
|
+
// Best-effort, like the call site that uses it.
|
|
838
|
+
}
|
|
839
|
+
})
|
|
840
|
+
|
|
314
841
|
let result: SubagentResultLike | null = null
|
|
315
842
|
let error: string | null = null
|
|
316
843
|
let continuable: ContinuableDelegationLike | null = null
|
|
844
|
+
// T36: a parked round is NOT an error — it means the child has not settled yet.
|
|
845
|
+
// Kept apart from `error` so a turn-shaped caller can resume instead of treating
|
|
846
|
+
// an ordinary wait as a failure of the delegation.
|
|
847
|
+
let parked = false
|
|
848
|
+
let parkedReason: string | null = null
|
|
849
|
+
// The run directory the operation index lives under. Computed once, and the
|
|
850
|
+
// index is skipped entirely when there is no root: `join('', …)` would otherwise
|
|
851
|
+
// resolve a RELATIVE path and could read or write an unrelated directory.
|
|
852
|
+
const operationsDir = input.root ? join(input.root, '.recursive', 'run', input.runId) : ''
|
|
853
|
+
|
|
854
|
+
// T19 — a RECOGNISED repeat is not re-executed. Only an operation that already
|
|
855
|
+
// COMPLETED and was ACCEPTED blocks a retry: a failed or refused attempt must
|
|
856
|
+
// stay retryable, and a repaired artifact is a different operation entirely
|
|
857
|
+
// because its body is part of the id. Without this the same review could spawn a
|
|
858
|
+
// second reviewer for an artifact that has not changed.
|
|
859
|
+
const prior = operationsDir === '' ? null : findOperation(operationsDir, operation)
|
|
860
|
+
// ⚠ FU-18 — A CONTINUATION IS NOT A REPEAT, and the distinction has to live HERE rather than in the operation
|
|
861
|
+
// id. The tempting fix is to fold the round into the id so a feedback round becomes a different operation,
|
|
862
|
+
// and it is the wrong one: T28's children budget below counts DISTINCT OPERATIONS and its own comment says a
|
|
863
|
+
// resumed turn "re-enters here with the same id and must not be counted as another child". Making a
|
|
864
|
+
// continuation a new identity would fix this refusal and silently miscount the budget instead.
|
|
865
|
+
//
|
|
866
|
+
// The collision it resolves: the T19 guard refuses an operation already accepted against an UNCHANGED
|
|
867
|
+
// artifact, which is right in general — re-reviewing an artifact nothing has touched since it passed wastes a
|
|
868
|
+
// child. But when the main agent sends FEEDBACK, the artifact has not changed YET: the child has not repaired
|
|
869
|
+
// it. So the guard refused exactly the call that starts the repair. A continuation is the operation still
|
|
870
|
+
// running, so it is exempt; a genuinely fresh delegation against an unchanged artifact is still refused.
|
|
871
|
+
if (prior?.outcome === 'accepted' && input.continuing !== true) {
|
|
872
|
+
throw new Error(
|
|
873
|
+
'review of ' + input.phase + ' is a recognised repeat of an operation already accepted (operation ' +
|
|
874
|
+
operation + '); the artifact has not changed since it passed',
|
|
875
|
+
)
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// T28 — THE CHILDREN BUDGET, counted FROM the index rather than tracked beside it,
|
|
879
|
+
// so the bound cannot drift from the operations it bounds. Distinct operations,
|
|
880
|
+
// not records: a resumed turn re-enters here with the same id and must not be
|
|
881
|
+
// counted as another child.
|
|
882
|
+
if (operationsDir !== '') {
|
|
883
|
+
const started = countOperations(operationsDir, 'delegate-review', input.phase)
|
|
884
|
+
if (started >= this.enforcementConfig.budgets.maxChildrenPerPhase) {
|
|
885
|
+
throw new Error(
|
|
886
|
+
'children budget reached: phase ' + input.phase + ' has already started ' + started +
|
|
887
|
+
' delegation(s), and the configured cap is ' + this.enforcementConfig.budgets.maxChildrenPerPhase,
|
|
888
|
+
)
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
|
|
317
892
|
if (decision.tier === 'native' || decision.tier === 'external-cli') {
|
|
318
|
-
if (!
|
|
893
|
+
if (!subagents) {
|
|
319
894
|
error = 'no ctx.subagents runtime available (self-audit fallback)'
|
|
320
|
-
} else if (input.mode
|
|
321
|
-
//
|
|
895
|
+
} else if (input.mode !== 'one-shot') {
|
|
896
|
+
// T35: continuable BY DEFAULT — only an explicit 'one-shot' opts out of
|
|
897
|
+
// the repair path, because a one-shot child cannot be resumed.
|
|
322
898
|
continuable = await delegateContinuable({
|
|
323
|
-
subagents
|
|
324
|
-
provider: decision.provider as string,
|
|
899
|
+
subagents,
|
|
900
|
+
provider: (target.provider ?? decision.provider ?? '') as string,
|
|
325
901
|
label: input.delegationId + '/' + input.childId,
|
|
326
902
|
prompt,
|
|
327
903
|
childId: input.childId,
|
|
328
|
-
maxDepth: input.maxDepth
|
|
904
|
+
maxDepth: remainingDepthFor(this.enforcementConfig.budgets, parentDepthOf(input.parent), input.maxDepth),
|
|
329
905
|
toolFilter: input.toolFilter ?? defaultReviewToolFilter(),
|
|
330
906
|
maxRounds: input.maxRounds ?? 3,
|
|
331
|
-
|
|
907
|
+
// T9: the model the role resolved to, when the provider accepts overrides. It must be
|
|
908
|
+
// forwarded THROUGH the continuable delegation — that seam builds the start request
|
|
909
|
+
// itself, so setting it on `request` alone would never reach the provider.
|
|
910
|
+
...(request.agentOptions === undefined ? {} : { agentOptions: request.agentOptions }),
|
|
911
|
+
// T39: the ROUND AWAIT is the hangable part of a delegation — the child may work for
|
|
912
|
+
// minutes — so it is what the board should be able to see as a running job.
|
|
913
|
+
//
|
|
914
|
+
// ⚠ SAFETY BY CONSTRUCTION, which is why this wrapper is safe to add at all: the
|
|
915
|
+
// seam's contract is `SubagentResultLike | null`, and `delegateContinuable` reads a
|
|
916
|
+
// null as PARKED. So every non-completed job outcome returns `null` — never a throw
|
|
917
|
+
// and never an invented result — which means a killed or failed round parks exactly
|
|
918
|
+
// as an unobserved round does. This wrapper therefore CANNOT convert a parked round
|
|
919
|
+
// into a settlement, and the property holds without a special case.
|
|
920
|
+
//
|
|
921
|
+
// ⚠ MEASURED, and it corrects T39's own plan: `delegateReview` has NO interrupt
|
|
922
|
+
// seam — its input carries none, and this module has no interrupt path (the audit
|
|
923
|
+
// loop has one only because IT receives the teams runtime). So the job's cancel
|
|
924
|
+
// cannot yet pre-empt the child; wiring that needs the seam added here first. The
|
|
925
|
+
// job still gives the board visibility, and the signal is honoured at the boundary.
|
|
926
|
+
awaitRoundResult: input.awaitRoundResult === undefined ? undefined : async (childId, messageId) => {
|
|
927
|
+
const awaited = await runTracked(this.jobs, {
|
|
928
|
+
kind: 'delegation',
|
|
929
|
+
label: 'delegation round ' + input.runId + ' ' + input.phase,
|
|
930
|
+
run: async ({ report, signal }) => {
|
|
931
|
+
report('awaiting the review round')
|
|
932
|
+
|
|
933
|
+
// T39 (part 2) — THE PRE-EMPTIVE KILL. A job kill aborts the signal, and this
|
|
934
|
+
// listener turns that abort into an INTERRUPT of the LIVE CHILD, so the kill
|
|
935
|
+
// stops the WORK rather than merely stopping our wait for it. That is the
|
|
936
|
+
// difference between this call site and T10's synchronous ones, where no such
|
|
937
|
+
// thing is possible.
|
|
938
|
+
//
|
|
939
|
+
// The seam is OPTIONAL and the no-seam case stays honest: without a provider
|
|
940
|
+
// the kill still parks the round and never throws — it simply cannot reach the
|
|
941
|
+
// child, and nothing here pretends otherwise.
|
|
942
|
+
const onAbort = () => {
|
|
943
|
+
try {
|
|
944
|
+
interruptChild(input.childId, abortReason(signal))
|
|
945
|
+
} catch {
|
|
946
|
+
// Best-effort by design: an interrupt that throws must not replace the
|
|
947
|
+
// kill's own outcome with an unrelated error.
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
if (signal?.aborted === true) onAbort()
|
|
951
|
+
else signal?.addEventListener('abort', onAbort, { once: true })
|
|
952
|
+
|
|
953
|
+
try {
|
|
954
|
+
// ⚠ FU-9 — THE `?.` IS THE FIX FOR THE WHOLE INVESTIGATION. `signal` is typed as an
|
|
955
|
+
// `AbortSignal` but a caller can reach here with nothing, and `signal.aborted` on undefined
|
|
956
|
+
// throws `Cannot read properties of undefined (reading 'aborted')` — which the delegation's
|
|
957
|
+
// catch recorded as its reason, the driver misread as "no continuable repair path", and which
|
|
958
|
+
// therefore meant THE CHILD NEVER STARTED. Every artifact this investigation chased — no child
|
|
959
|
+
// session, no reply, no settlement, four brief-only child directories — was downstream of this
|
|
960
|
+
// one unguarded property read. An absent signal means "nobody can cancel this", not "crash".
|
|
961
|
+
if (signal?.aborted === true) throw new Error('the round was cancelled before it was awaited')
|
|
962
|
+
return await input.awaitRoundResult!(childId, messageId)
|
|
963
|
+
} finally {
|
|
964
|
+
signal?.removeEventListener('abort', onAbort)
|
|
965
|
+
}
|
|
966
|
+
},
|
|
967
|
+
})
|
|
968
|
+
if (awaited.status !== 'completed') return null
|
|
969
|
+
// T39: record the round where the run id is known — same pattern as the lint, and
|
|
970
|
+
// for the same reason (an event would carry the job, not the run).
|
|
971
|
+
recordJobRun(input.root, input.runId, {
|
|
972
|
+
kind: 'delegation',
|
|
973
|
+
label: 'delegation round ' + input.runId + ' ' + input.phase,
|
|
974
|
+
result: awaited,
|
|
975
|
+
})
|
|
976
|
+
return awaited.value ?? null
|
|
977
|
+
},
|
|
332
978
|
parent: input.parent,
|
|
333
979
|
})
|
|
334
|
-
if (continuable.
|
|
980
|
+
if (continuable.parked === true) {
|
|
981
|
+
// T36: no settlement has landed for this round yet. The child is still
|
|
982
|
+
// working (or a repair was just sent), so the caller resumes on a later
|
|
983
|
+
// turn with the SAME child id. `accepted` stays false, and no `result` is
|
|
984
|
+
// fabricated — an unobserved round is never an approval.
|
|
985
|
+
parked = true
|
|
986
|
+
parkedReason = continuable.reason ?? null
|
|
987
|
+
} else if (continuable.fellBackToOneShot) {
|
|
335
988
|
// The seam has no continuable capability — keep the one-shot result.
|
|
336
989
|
result = continuable.rounds[0]?.result ?? null
|
|
337
990
|
if (!result) error = 'continuable fallback produced no result'
|
|
@@ -344,8 +997,8 @@ export class RecursiveRuntime extends Service {
|
|
|
344
997
|
} else {
|
|
345
998
|
try {
|
|
346
999
|
result = await delegate({
|
|
347
|
-
subagents
|
|
348
|
-
provider: decision.provider as string,
|
|
1000
|
+
subagents,
|
|
1001
|
+
provider: (target.provider ?? decision.provider ?? '') as string,
|
|
349
1002
|
request,
|
|
350
1003
|
})
|
|
351
1004
|
} catch (err) {
|
|
@@ -356,7 +1009,41 @@ export class RecursiveRuntime extends Service {
|
|
|
356
1009
|
error = 'delegation resolved to ' + decision.tier + ' (' + decision.reason + ')'
|
|
357
1010
|
}
|
|
358
1011
|
|
|
359
|
-
const
|
|
1012
|
+
const rawEvaluation = result ? evaluateDelegationResult(result) : { accepted: false, reason: error ?? 'no result' }
|
|
1013
|
+
|
|
1014
|
+
// T8 — THE WIRING BUG THE ITEM'S RESCOPE NAMED. `validateReferences` existed, was
|
|
1015
|
+
// exported, was even reachable through this runtime — and was called by NOTHING on the
|
|
1016
|
+
// delegate path. The module header had always claimed the opposite ("validates the
|
|
1017
|
+
// child's references before writing an action record"), so a reviewer could cite files
|
|
1018
|
+
// that do not exist, or paths that escape the root, and the review was recorded as a
|
|
1019
|
+
// PASS. A claim nobody checks is worse than no claim, because it reads as evidence.
|
|
1020
|
+
//
|
|
1021
|
+
// Placed BEFORE the operation record and the action record, which is what makes it
|
|
1022
|
+
// matter: a review with unverifiable references is never indexed as `accepted`, so the
|
|
1023
|
+
// T19 repeat guard treats it as retryable instead of freezing a bad review in place.
|
|
1024
|
+
//
|
|
1025
|
+
// A delegation that claims NOTHING is not checked — refusing an empty list would be a
|
|
1026
|
+
// policy change (is a reference-free review invalid?) rather than a wiring fix, and it
|
|
1027
|
+
// is recorded here as a deliberate boundary rather than an oversight.
|
|
1028
|
+
const claims = result ? referencesFromResult(result) : []
|
|
1029
|
+
const referenceCheck = claims.length === 0 ? null : validateReferences(input.root, claims)
|
|
1030
|
+
const evaluation = referenceCheck === null || referenceCheck.ok
|
|
1031
|
+
? rawEvaluation
|
|
1032
|
+
: { accepted: false, reason: 'review references failed: ' + referenceCheck.failures.join('; ') }
|
|
1033
|
+
|
|
1034
|
+
// T19 — persist the attempt so a RESTART can match it. Only an accepted review is
|
|
1035
|
+
// recorded as `accepted`, which is what makes the recognition above block a
|
|
1036
|
+
// genuine repeat while leaving every failure path retryable. Best-effort: a
|
|
1037
|
+
// failed index write must never change the outcome of a review that already ran.
|
|
1038
|
+
if (operationsDir !== '') {
|
|
1039
|
+
recordOperation(operationsDir, {
|
|
1040
|
+
id: operation,
|
|
1041
|
+
act: 'delegate-review',
|
|
1042
|
+
at: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'),
|
|
1043
|
+
outcome: evaluation.accepted ? 'accepted' : 'unaccepted',
|
|
1044
|
+
phase: input.phase,
|
|
1045
|
+
})
|
|
1046
|
+
}
|
|
360
1047
|
|
|
361
1048
|
// R6: record the attempt as an action record (accepted only if evaluation passes).
|
|
362
1049
|
const actionRecordPath = writeActionRecord({
|
|
@@ -364,8 +1051,10 @@ export class RecursiveRuntime extends Service {
|
|
|
364
1051
|
runId: input.runId,
|
|
365
1052
|
subagentId: input.childId,
|
|
366
1053
|
phase: input.phase,
|
|
367
|
-
|
|
368
|
-
|
|
1054
|
+
// ⚠ FU-17 — the kind is stated in the record. `Status` says accepted or failed; nothing said whether the
|
|
1055
|
+
// child PRODUCED the phase's work or JUDGED it, and a reader of a run could not tell the two apart.
|
|
1056
|
+
purpose: input.role + (input.kind === 'work' ? ' (work)' : '') + ' for run ' + input.runId,
|
|
1057
|
+
executionMode: decision.tier + (input.mode !== 'one-shot' ? ' (continuable)' : ''),
|
|
369
1058
|
artifactPath: input.artifactPath,
|
|
370
1059
|
upstreamArtifacts: input.upstreamArtifacts,
|
|
371
1060
|
reviewBundle: bundle.repoRelativePath,
|
|
@@ -375,8 +1064,44 @@ export class RecursiveRuntime extends Service {
|
|
|
375
1064
|
findings: evaluation.accepted && result?.structured ? [(result.structured as { verdict?: string })?.verdict ?? 'accepted'] : undefined,
|
|
376
1065
|
success: evaluation.accepted,
|
|
377
1066
|
stopReason: result?.stopReason,
|
|
1067
|
+
// ⚠ FU-9 — AND SAY WHICH KIND OF FAILURE, because the record previously could not. `result == null` means
|
|
1068
|
+
// the provider never produced anything at all (never started, or returned nothing) — which is what the
|
|
1069
|
+
// live record's `Stop Reason: n/a` was quietly telling me — while a present result that failed to be
|
|
1070
|
+
// accepted means a child DID run and its work was refused. Different problems, identical artifacts.
|
|
1071
|
+
failure: evaluation.accepted
|
|
1072
|
+
? undefined
|
|
1073
|
+
: result == null
|
|
1074
|
+
// ⚠ THE TWO STATES ARE NOT THE SAME AND THE MESSAGE USED TO CONFLATE THEM. The one-shot path cannot
|
|
1075
|
+
// resolve to nothing — the host's `start` returns a run or throws (assertCapabilities, expectProvider)
|
|
1076
|
+
// — so a null result on the CONTINUABLE path means the opposite of what I first wrote: the start WAS
|
|
1077
|
+
// made and NO SETTLEMENT ARRIVED within the wait. That distinction cost me two rounds of looking at
|
|
1078
|
+
// provider names, so the record now states which path a run took and what it was waiting for.
|
|
1079
|
+
? (input.mode !== 'one-shot'
|
|
1080
|
+
? 'the continuable start was made and NO SETTLEMENT arrived within the wait (the child never reported,'
|
|
1081
|
+
+ ' or never ran); tier ' + decision.tier + ', provider ' + (decision.provider ?? 'none chosen')
|
|
1082
|
+
+ ', names on offer [' + (this.lastProviderNames.join(', ') || 'none') + ']'
|
|
1083
|
+
// ⚠ FU-9 — THE PARENT IDENTITY, because the host refuses a prompt when it cannot resolve the
|
|
1084
|
+
// parent session as a live Agent (`subagent/parent-unavailable`, index.ts L429-436), and the tool
|
|
1085
|
+
// builds this handle with a CAST (`exec.agent as unknown as SubagentParentHandle`). A cast is not
|
|
1086
|
+
// a contract: if the id here is not the one the host looks up, the refusal is real and the
|
|
1087
|
+
// classifier's crash has been hiding it. Printing it here costs nothing and settles the question.
|
|
1088
|
+
+ '; parent id ' + ((input.parent as { id?: string } | undefined)?.id ?? 'none')
|
|
1089
|
+
+ ', parent session keys [' + (input.parent === undefined ? 'no parent' : Object.keys(input.parent as object).join(', ')) + ']'
|
|
1090
|
+
: 'no delegate result was produced by the one-shot path; tier ' + decision.tier
|
|
1091
|
+
+ ', provider ' + (decision.provider ?? 'none chosen'))
|
|
1092
|
+
: 'the delegation returned without acceptance; stop reason ' + (result.stopReason ?? 'none reported'),
|
|
378
1093
|
})
|
|
379
1094
|
|
|
1095
|
+
// T35: report the mode that ACTUALLY ran, not the one that was asked for. A
|
|
1096
|
+
// missing continuable capability is a real loss — the review can no longer be
|
|
1097
|
+
// sent back to the child that did the work — so it is NAMED rather than
|
|
1098
|
+
// hidden behind a generic success. `continuable-unavailable` is the honest
|
|
1099
|
+
// label for that case: the delegation still happened, the repair path did not.
|
|
1100
|
+
const delegationMode: 'continuable' | 'one-shot' | 'continuable-unavailable' | 'none'
|
|
1101
|
+
= continuable !== null
|
|
1102
|
+
? (continuable.fellBackToOneShot ? 'continuable-unavailable' : 'continuable')
|
|
1103
|
+
: result !== null ? 'one-shot' : 'none'
|
|
1104
|
+
|
|
380
1105
|
// T4: the durable child id is reported for the caller (a tool/closeout that
|
|
381
1106
|
// holds the live parent Agent may drain it explicitly); the HOST owns the
|
|
382
1107
|
// teardown drain (drainContinuableDescendants) at session close — this loop
|
|
@@ -393,9 +1118,28 @@ export class RecursiveRuntime extends Service {
|
|
|
393
1118
|
request,
|
|
394
1119
|
result,
|
|
395
1120
|
evaluation,
|
|
1121
|
+
/** T9: routing decisions that could NOT be applied, so a caller is told rather than left to infer. */
|
|
1122
|
+
routingNotes,
|
|
396
1123
|
actionRecordPath,
|
|
397
1124
|
error,
|
|
398
|
-
|
|
1125
|
+
/** T35: which child lifecycle actually carried this delegation. */
|
|
1126
|
+
delegationMode,
|
|
1127
|
+
/** T19: the deterministic id of this review, persisted so a restart can match it. */
|
|
1128
|
+
operationId: operation,
|
|
1129
|
+
/**
|
|
1130
|
+
* T36: true when the round has NOT settled yet, so the caller resumes with
|
|
1131
|
+
* `continuable.childId` on a later turn. `parkedReason` carries the loop's own
|
|
1132
|
+
* sentence ("the child is still working") without it being an `error`.
|
|
1133
|
+
*/
|
|
1134
|
+
parked,
|
|
1135
|
+
parkedReason,
|
|
1136
|
+
continuable: continuable ? { rounds: continuable.rounds, childId: continuable.childId, fellBackToOneShot: continuable.fellBackToOneShot, parked: continuable.parked === true,
|
|
1137
|
+
// ⚠ FU-9 — `ok` AND `reason` TRAVEL WITH IT. The adapter that builds the review driver's view read only
|
|
1138
|
+
// `rounds`, `childId`, `fellBackToOneShot` and `parked`, so every failure branch's REASON — the whole
|
|
1139
|
+
// point of the field — was discarded one layer above the message that needed it, and `ok: false` was
|
|
1140
|
+
// replaced by a hardcoded `ok: true`. A driver cannot report which branch fired if the branch's own
|
|
1141
|
+
// name never reaches it.
|
|
1142
|
+
ok: continuable.ok, reason: continuable.reason } : null,
|
|
399
1143
|
}
|
|
400
1144
|
}
|
|
401
1145
|
|
|
@@ -406,7 +1150,7 @@ export class RecursiveRuntime extends Service {
|
|
|
406
1150
|
|
|
407
1151
|
/** R7: probe availability for a role and render the decision basis prose. */
|
|
408
1152
|
probeDelegation(root: string, role: string, providers: Record<string, SubagentProviderLike> = {}) {
|
|
409
|
-
const policy = loadRouterPolicy(routerPolicyPath(root))
|
|
1153
|
+
const policy = loadRouterPolicy(routerPolicyPath(root), this._routerOverrides)
|
|
410
1154
|
const decision = resolveRole(role, policy, providers)
|
|
411
1155
|
const probe = capabilityProbe({ providers, role, policy })
|
|
412
1156
|
return {
|
|
@@ -450,12 +1194,36 @@ export class RecursiveRuntime extends Service {
|
|
|
450
1194
|
return null
|
|
451
1195
|
}
|
|
452
1196
|
|
|
453
|
-
async status(runId?: string, agent?: { session?: { header?: { cwd?: string } } } | null): Promise<
|
|
1197
|
+
async status(runId?: string, agent?: { session?: { header?: { cwd?: string } } } | null): Promise<RecursiveStatusWithGuardDecisions | null> {
|
|
454
1198
|
const root = await this.resolveRootFor(agent)
|
|
455
1199
|
if (!root) return null
|
|
456
1200
|
const resolved = resolveRunDir(root, runId)
|
|
457
1201
|
if (!resolved) return null
|
|
458
|
-
|
|
1202
|
+
// T15 (G): surface the rolling guard-decision evidence BESIDE the fold, spread
|
|
1203
|
+
// over it — foldRun's own output shape is parity-asserted (status.parity) and
|
|
1204
|
+
// must not change. Scoped to the resolved run so the field answers "what did
|
|
1205
|
+
// the guard decide about THIS run", newest first.
|
|
1206
|
+
const guardDecisions = readGuardDecisions(root, GUARD_DECISION_READ_LIMIT)
|
|
1207
|
+
.filter((d) => d.runId === resolved.runId)
|
|
1208
|
+
.slice(0, GUARD_DECISION_SURFACE_LIMIT)
|
|
1209
|
+
// T18: the pending set rides beside the fold for the same reason — foldRun's
|
|
1210
|
+
// shape is parity-asserted. Always present (empty when nothing is in flight)
|
|
1211
|
+
// so a consumer needs no null dance, and DERIVED on every call rather than
|
|
1212
|
+
// stored, so it cannot go stale.
|
|
1213
|
+
//
|
|
1214
|
+
// T32: the receipt-chain verdict rides here too. A chain that has been edited or
|
|
1215
|
+
// spliced must be VISIBLE on the status a caller actually reads, not only inside
|
|
1216
|
+
// a test — that was the whole finding: the mechanism was written and read by
|
|
1217
|
+
// nothing. Read-only, so asking cannot change the answer.
|
|
1218
|
+
return {
|
|
1219
|
+
...foldRun(resolved.runDir, resolved.runId),
|
|
1220
|
+
guardDecisions,
|
|
1221
|
+
pendingWork: pendingWork(resolved.runDir),
|
|
1222
|
+
receiptChain: validateReceiptChain(resolved.runDir, resolved.runId),
|
|
1223
|
+
// T22: the same identifier the prompt's stable prefix carries, so a reader can compare status
|
|
1224
|
+
// against prompt without re-rendering the section.
|
|
1225
|
+
contractDigest: contractDigest(this.enforcementConfig),
|
|
1226
|
+
}
|
|
459
1227
|
}
|
|
460
1228
|
|
|
461
1229
|
/**
|
|
@@ -465,14 +1233,68 @@ export class RecursiveRuntime extends Service {
|
|
|
465
1233
|
* rules + instructions. Returns null when no active phase exists. This is the
|
|
466
1234
|
* canonical data source for the recursive_phase tool.
|
|
467
1235
|
*/
|
|
468
|
-
async phaseRules(runId?: string, agent?: { session?: { header?: { cwd?: string } } } | null): Promise<(PhaseRules & { runId: string; phase: string }) | null> {
|
|
1236
|
+
async phaseRules(runId?: string, agent?: { session?: { header?: { cwd?: string } } } | null, files?: readonly string[]): Promise<(PhaseRules & { runId: string; phase: string; memory: string; memoryReason: string }) | null> {
|
|
469
1237
|
const root = await this.resolveRootFor(agent)
|
|
470
1238
|
if (!root) return null
|
|
471
1239
|
const resolved = resolveRunDir(root, runId)
|
|
472
1240
|
if (!resolved) return null
|
|
473
1241
|
const phase = getNextLegalPhase(resolved.runDir)
|
|
474
1242
|
if (!phase) return null
|
|
475
|
-
|
|
1243
|
+
// T29 — MEMORY AT RUN ENTRY, ON THIS CALL AND NOWHERE ELSE.
|
|
1244
|
+
//
|
|
1245
|
+
// ⚠ THE ONCE-GATE IS THIS FUNCTION, not a mechanism added beside it: `recursive_phase` calls it
|
|
1246
|
+
// once per phase entry, so riding the injection on its EXISTING return is what keeps it
|
|
1247
|
+
// once-per-run instead of once-per-turn. A second dedupe would be a second thing to get wrong.
|
|
1248
|
+
//
|
|
1249
|
+
// ⚠ AND NOTHING RELEVANT INJECTS NOTHING — `memory` is the empty string, with the reason saying
|
|
1250
|
+
// why, rather than a section that fabricates relevance the plane does not have.
|
|
1251
|
+
const requirements = (() => {
|
|
1252
|
+
try {
|
|
1253
|
+
return readFileSync(join(resolved.runDir, '00-requirements.md'), 'utf8')
|
|
1254
|
+
} catch {
|
|
1255
|
+
// A run with no requirements yet has a weaker query, not an error: the paths still count.
|
|
1256
|
+
return ''
|
|
1257
|
+
}
|
|
1258
|
+
})()
|
|
1259
|
+
// `phase` IS the artifact file name (`getNextLegalPhase` returns one of PHASE_SEQUENCE), so the gate
|
|
1260
|
+
// lookup needs no mapping — and the artifact's own text is what says whether it is still owed.
|
|
1261
|
+
const pending = pendingGateFor(phase, (() => {
|
|
1262
|
+
try {
|
|
1263
|
+
return readFileSync(join(resolved.runDir, phase), 'utf8')
|
|
1264
|
+
} catch {
|
|
1265
|
+
return null
|
|
1266
|
+
}
|
|
1267
|
+
})())
|
|
1268
|
+
const selection = selectMemory(root, {
|
|
1269
|
+
query: requirements.slice(0, 4000),
|
|
1270
|
+
// FU-4: the run's OWN changed paths, computed when the caller supplies none — so T29's path
|
|
1271
|
+
// weighting is fed by a real run rather than only by tests. `[]` from a non-git root is fine: the
|
|
1272
|
+
// query still ranks, and a memory hint must never be why a phase call fails.
|
|
1273
|
+
files: files ?? changedPaths(root),
|
|
1274
|
+
// P2: the phase in play, so an entry declaring it applies here outranks general guidance.
|
|
1275
|
+
// P3b: the counters, read ONCE here and handed to the ranking — the book is evidence about retrieval,
|
|
1276
|
+
// and where it lives is the caller's business, not the ranker's.
|
|
1277
|
+
feedback: readFeedback(root),
|
|
1278
|
+
phase,
|
|
1279
|
+
})
|
|
1280
|
+
// P3b: record what this phase was shown, so the loop has evidence to settle at closeout.
|
|
1281
|
+
recordInjection(resolved.runDir, selection.shards.map((shard) => ({
|
|
1282
|
+
source: shard.entry.source,
|
|
1283
|
+
title: shard.entry.title,
|
|
1284
|
+
score: shard.score,
|
|
1285
|
+
})), phase)
|
|
1286
|
+
return {
|
|
1287
|
+
runId: resolved.runId,
|
|
1288
|
+
phase,
|
|
1289
|
+
...phaseRulesFor(phase),
|
|
1290
|
+
memory: selection.injected ? renderMemorySection(selection.shards.map((shard) => shard.entry)) : '',
|
|
1291
|
+
memoryReason: selection.reason,
|
|
1292
|
+
// FU-7 — THE PHASE-ENTRY CALL POINTS. Phase 03 owes a `TDD Mode` decision and phase 05 a
|
|
1293
|
+
// `QA Execution Mode` one; both are surfaced HERE, at the entry the tool already makes, so a
|
|
1294
|
+
// caller does not have to know the workflow's gate vocabulary to be asked the right question.
|
|
1295
|
+
// Omitted entirely once the artifact carries the marker (that IS the once-gate).
|
|
1296
|
+
...(pending === null ? {} : { ask: { gate: pending, ...buildAskQuestion(pending), artifact: GATE_DEFAULT_ARTIFACT[pending] } }),
|
|
1297
|
+
}
|
|
476
1298
|
}
|
|
477
1299
|
|
|
478
1300
|
/**
|
|
@@ -495,9 +1317,33 @@ export class RecursiveRuntime extends Service {
|
|
|
495
1317
|
let scaffoldRoot = root
|
|
496
1318
|
let worktree: CreateWorktreeResult | undefined
|
|
497
1319
|
if (opts?.createWorktree) {
|
|
498
|
-
worktree
|
|
1320
|
+
// T10: a linked-worktree create is the operation the item names as the "hung" one — git
|
|
1321
|
+
// can take a long time on a large repo — and like the linter it CANNOT stop mid-flight,
|
|
1322
|
+
// because `createLinkedWorktree` is synchronous. What the job buys is therefore the same
|
|
1323
|
+
// and is stated plainly: the board SEES it running and the caller can stop WAITING.
|
|
1324
|
+
//
|
|
1325
|
+
// The failure path is unchanged: `createLinkedWorktree` reports `{ ok: false, error }`
|
|
1326
|
+
// rather than throwing, so a refused create still throws exactly what it threw before.
|
|
1327
|
+
const created = await runTracked(this.jobs, {
|
|
1328
|
+
kind: 'worktree',
|
|
1329
|
+
label: 'worktree ' + runId,
|
|
1330
|
+
run: async ({ report, signal }) => {
|
|
1331
|
+
report('creating ' + runId)
|
|
1332
|
+
if (signal.aborted) throw new Error('worktree create cancelled before it started')
|
|
1333
|
+
return createLinkedWorktree({ repoRoot: root, runId, baseBranch: opts.baseBranch })
|
|
1334
|
+
},
|
|
1335
|
+
})
|
|
1336
|
+
if (created.status !== 'completed' || created.value === undefined) {
|
|
1337
|
+
throw new Error(created.detail ?? created.error ?? 'worktree create did not complete')
|
|
1338
|
+
}
|
|
1339
|
+
worktree = created.value
|
|
499
1340
|
if (!worktree.ok) throw new Error(worktree.error ?? 'worktree create failed')
|
|
500
1341
|
scaffoldRoot = worktree.worktreeDir
|
|
1342
|
+
// T39: recorded into the SCAFFOLD root, because a worktree run's layer lives INSIDE the
|
|
1343
|
+
// worktree — recording it against the main checkout would file it where no run exists.
|
|
1344
|
+
// A create that FAILED records nothing here: there is no run layer to record into, and
|
|
1345
|
+
// the failure already reaches the caller as the thrown error above.
|
|
1346
|
+
recordJobRun(scaffoldRoot, runId, { kind: 'worktree', label: 'worktree ' + runId, result: created })
|
|
501
1347
|
}
|
|
502
1348
|
const runDir = join(scaffoldRoot, '.recursive', 'run', runId)
|
|
503
1349
|
mkdirSync(runDir, { recursive: true })
|
|
@@ -614,6 +1460,16 @@ export class RecursiveRuntime extends Service {
|
|
|
614
1460
|
try { this.blockRunToGoal(agent, runId, { code: 'prerequisite-blockers', message }) } catch { /* best-effort */ }
|
|
615
1461
|
throw new Error('Prerequisite blockers: ' + blockers.map(b => b.artifact + ' (' + b.status + ')').join(', '))
|
|
616
1462
|
}
|
|
1463
|
+
// T18 — QUIESCENCE. A lock is only sound at a point where nothing is in
|
|
1464
|
+
// flight, so a run with an unresolved delegation refuses to lock and names
|
|
1465
|
+
// what is blocking. Checked AFTER the prerequisite gate deliberately: the
|
|
1466
|
+
// monotonic lock-order rule is the canonical one that the parity goldens and
|
|
1467
|
+
// every existing test know, and a run that is both out of order AND has a
|
|
1468
|
+
// delegation open still reports ordering first, exactly as before this item.
|
|
1469
|
+
const inFlight = pendingWork(runDir)
|
|
1470
|
+
if (inFlight.length > 0) {
|
|
1471
|
+
throw new Error(toolError('PENDING_WORK', inFlight.map((p) => p.detail).join('; ')))
|
|
1472
|
+
}
|
|
617
1473
|
let content = readFileSync(artifactPath, 'utf8')
|
|
618
1474
|
const lockedAt = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z')
|
|
619
1475
|
content = setOrInsertField(content, 'Status', 'LOCKED', ['Phase'])
|
|
@@ -639,10 +1495,51 @@ export class RecursiveRuntime extends Service {
|
|
|
639
1495
|
private reopenArtifact(root: string, runDir: string, runId: string, artifact: string, artifactPath: string, agent?: { session?: { header?: { cwd?: string } } } | null): LockArtifactResult {
|
|
640
1496
|
if (!existsSync(artifactPath)) throw new Error('Artifact not found: ' + artifact)
|
|
641
1497
|
let content = readFileSync(artifactPath, 'utf8')
|
|
1498
|
+
|
|
1499
|
+
// T19 — DETERMINISTIC OPERATION IDENTITY. Reopen is the one genuinely DESTRUCTIVE
|
|
1500
|
+
// operation here: it strips the lock fields and invalidates every downstream
|
|
1501
|
+
// receipt, so running it twice by accident destroys evidence. A retry after a
|
|
1502
|
+
// partial failure was previously indistinguishable from a fresh request.
|
|
1503
|
+
//
|
|
1504
|
+
// The id covers the INPUTS plus the STATE BEING REOPENED, and that third
|
|
1505
|
+
// component is what makes the rule correct rather than merely present:
|
|
1506
|
+
// - a retry after a FAILED reopen still sees the same state, because a failed
|
|
1507
|
+
// reopen leaves the artifact locked — so the retry IS recognised, which is
|
|
1508
|
+
// the case the item exists for;
|
|
1509
|
+
// - a DELIBERATE second reopen after a REPAIR sees a different state, so it is
|
|
1510
|
+
// a different operation and runs — the legitimate workflow stays intact.
|
|
1511
|
+
// Keying on `{runId, artifact}` alone would have made the second reopen a no-op
|
|
1512
|
+
// and quietly broken reopen→fix→lock→reopen.
|
|
1513
|
+
//
|
|
1514
|
+
// The state is the artifact's BODY — its content with the lock fields normalised
|
|
1515
|
+
// out — and NOT its `LockHash`. That distinction is a bug fix, found by T37's
|
|
1516
|
+
// flake hunt: `LockHash` covers `LockedAt`, which is wall-clock truncated to the
|
|
1517
|
+
// SECOND, so two locks of byte-identical content hash differently whenever the
|
|
1518
|
+
// clock crosses a second boundary. Keying on it made this guard fire or not
|
|
1519
|
+
// depending on timing — it failed 6 of 12 measured runs. The body is stable
|
|
1520
|
+
// across a re-lock of identical content and differs as soon as the author
|
|
1521
|
+
// changes anything that matters.
|
|
1522
|
+
const bodyAtEntry = lockHashFromContent(
|
|
1523
|
+
content.replace(/^[ \t]*Status:.*$/m, '').replace(/^[ \t]*LockedAt:.*\n?/m, ''),
|
|
1524
|
+
)
|
|
1525
|
+
const operation = operationId({ act: 'reopen', input: { runId, artifact, body: bodyAtEntry } })
|
|
1526
|
+
if (findOperation(runDir, operation)?.outcome === 'applied') {
|
|
1527
|
+
// This exact operation already completed on this exact state. Reported as a
|
|
1528
|
+
// recognised repeat rather than executed again — a second strip-and-invalidate
|
|
1529
|
+
// would destroy whatever the first one left behind.
|
|
1530
|
+
throw new Error(
|
|
1531
|
+
'reopen ' + artifact + ' is a recognised repeat of an operation already applied (operation ' +
|
|
1532
|
+
operation + '); refusing to reopen it a second time',
|
|
1533
|
+
)
|
|
1534
|
+
}
|
|
1535
|
+
|
|
642
1536
|
content = content.replace(/^[ \t]*Status:.*$/m, 'Status: `DRAFT`')
|
|
643
1537
|
content = content.replace(/^[ \t]*LockedAt:.*\n?/m, '')
|
|
644
1538
|
content = content.replace(/^[ \t]*LockHash:.*\n?/m, '')
|
|
645
1539
|
writeFileSync(artifactPath, content, 'utf8')
|
|
1540
|
+
// Record the attempt AFTER it succeeded, best-effort: a failed index write must
|
|
1541
|
+
// never change the outcome of an operation the caller already performed.
|
|
1542
|
+
recordOperation(runDir, { id: operation, act: 'reopen', at: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'), outcome: 'applied' })
|
|
646
1543
|
const hadReceipt = invalidateReceipt(runDir, artifact)
|
|
647
1544
|
const stale = getStaleDownstreamPhases(runDir, artifact)
|
|
648
1545
|
for (const entry of stale) invalidateReceipt(runDir, entry.artifact)
|
|
@@ -675,8 +1572,40 @@ export class RecursiveRuntime extends Service {
|
|
|
675
1572
|
if (!existsSync(artifactPath)) {
|
|
676
1573
|
return { artifact: target, runId, errors: ['Artifact not found'], warnings: [], passed: false }
|
|
677
1574
|
}
|
|
678
|
-
const
|
|
679
|
-
|
|
1575
|
+
const linted = await runTracked(this.jobs, {
|
|
1576
|
+
kind: 'lint',
|
|
1577
|
+
// The label is what the board shows, so it names the run AND the artifact.
|
|
1578
|
+
label: 'lint ' + runId + ' ' + target,
|
|
1579
|
+
// NO TIMEOUT HERE, deliberately: a deadline would silently convert a slow-but-working
|
|
1580
|
+
// lint into a failure, which is a policy change rather than visibility. The job gives
|
|
1581
|
+
// the board a running entry and a kill switch, which is what the item asks for;
|
|
1582
|
+
// `runTracked`'s `timeoutMs` stays available to a caller who wants a deadline.
|
|
1583
|
+
run: async ({ report, signal }) => {
|
|
1584
|
+
report('linting ' + target)
|
|
1585
|
+
// A synchronous in-process linter cannot be interrupted mid-call, so the signal is
|
|
1586
|
+
// honoured at the BOUNDARY. Stated plainly rather than implied: a kill settles the
|
|
1587
|
+
// JOB — the board and the caller stop waiting — but a hung `lintRun` call itself
|
|
1588
|
+
// runs to completion in this process, because JavaScript cannot pre-empt it.
|
|
1589
|
+
if (signal.aborted) throw new Error('lint cancelled before it started')
|
|
1590
|
+
return lintRun(root, runId)
|
|
1591
|
+
},
|
|
1592
|
+
})
|
|
1593
|
+
// T39: record the run in the RUN LAYER, where the run id is known — see job-log.ts for why
|
|
1594
|
+
// this is a file rather than an event subscription (an event carries a job id, not the run
|
|
1595
|
+
// it belongs to, and parsing run ids back out of labels is a mapping that breaks silently).
|
|
1596
|
+
recordJobRun(root, runId, { kind: 'lint', label: 'lint ' + runId + ' ' + target, result: linted })
|
|
1597
|
+
if (linted.status !== 'completed' || linted.value === undefined) {
|
|
1598
|
+
// A killed or failed job is reported through the EXISTING error path, so the tool's
|
|
1599
|
+
// payload shape does not change and every existing assertion still holds.
|
|
1600
|
+
return {
|
|
1601
|
+
artifact: target,
|
|
1602
|
+
runId,
|
|
1603
|
+
errors: [linted.detail ?? linted.error ?? 'lint did not complete'],
|
|
1604
|
+
warnings: [],
|
|
1605
|
+
passed: false,
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
const stdout = [...linted.value.errors, ...linted.value.warnings].join('\n')
|
|
680
1609
|
return parseLintOutput(stdout, target, runId)
|
|
681
1610
|
}
|
|
682
1611
|
|