@nifrajs/coding-agent 4.0.0 → 5.0.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 +14 -2
- package/dist/cli.d.ts +13 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +120 -100
- package/dist/cli.js.map +1 -1
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +9 -21
- package/dist/context.js.map +1 -1
- package/dist/diff.d.ts.map +1 -1
- package/dist/diff.js +8 -21
- package/dist/diff.js.map +1 -1
- package/dist/handoffs.d.ts +5 -0
- package/dist/handoffs.d.ts.map +1 -1
- package/dist/handoffs.js +27 -0
- package/dist/handoffs.js.map +1 -1
- package/dist/host.d.ts +15 -1
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +117 -13
- package/dist/host.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/native.d.ts +22 -0
- package/dist/native.d.ts.map +1 -1
- package/dist/native.js +75 -18
- package/dist/native.js.map +1 -1
- package/dist/orchestration/dispatch.d.ts +5 -1
- package/dist/orchestration/dispatch.d.ts.map +1 -1
- package/dist/orchestration/dispatch.js +1 -1
- package/dist/orchestration/dispatch.js.map +1 -1
- package/dist/orchestration/evidence-store.d.ts.map +1 -1
- package/dist/orchestration/evidence-store.js +5 -6
- package/dist/orchestration/evidence-store.js.map +1 -1
- package/dist/orchestration/host.d.ts +14 -0
- package/dist/orchestration/host.d.ts.map +1 -1
- package/dist/orchestration/host.js +26 -0
- package/dist/orchestration/host.js.map +1 -1
- package/dist/orchestration/index.d.ts +1 -1
- package/dist/orchestration/index.d.ts.map +1 -1
- package/dist/orchestration/index.js.map +1 -1
- package/dist/orchestration/recovery.d.ts +5 -1
- package/dist/orchestration/recovery.d.ts.map +1 -1
- package/dist/orchestration/recovery.js +5 -3
- package/dist/orchestration/recovery.js.map +1 -1
- package/dist/process.d.ts +22 -1
- package/dist/process.d.ts.map +1 -1
- package/dist/process.js +63 -1
- package/dist/process.js.map +1 -1
- package/dist/rpc.d.ts +23 -0
- package/dist/rpc.d.ts.map +1 -1
- package/dist/rpc.js +204 -46
- package/dist/rpc.js.map +1 -1
- package/dist/session-migration.d.ts.map +1 -1
- package/dist/session-migration.js +30 -6
- package/dist/session-migration.js.map +1 -1
- package/dist/sessions.d.ts +13 -2
- package/dist/sessions.d.ts.map +1 -1
- package/dist/sessions.js +104 -24
- package/dist/sessions.js.map +1 -1
- package/dist/subagents.d.ts.map +1 -1
- package/dist/subagents.js +13 -2
- package/dist/subagents.js.map +1 -1
- package/dist/turn-replay.d.ts +40 -0
- package/dist/turn-replay.d.ts.map +1 -0
- package/dist/turn-replay.js +136 -0
- package/dist/turn-replay.js.map +1 -0
- package/dist/ui.js +84 -30
- package/dist/ui.js.map +1 -1
- package/dist/verification.d.ts +7 -0
- package/dist/verification.d.ts.map +1 -1
- package/dist/verification.js +66 -63
- package/dist/verification.js.map +1 -1
- package/dist/workflows.d.ts +5 -0
- package/dist/workflows.d.ts.map +1 -1
- package/dist/workflows.js +2 -1
- package/dist/workflows.js.map +1 -1
- package/package.json +6 -6
- package/src/cli.ts +125 -93
- package/src/context.ts +12 -20
- package/src/diff.ts +11 -23
- package/src/handoffs.ts +27 -0
- package/src/host.ts +134 -12
- package/src/index.ts +1 -0
- package/src/native.ts +118 -21
- package/src/orchestration/dispatch.ts +6 -2
- package/src/orchestration/evidence-store.ts +5 -6
- package/src/orchestration/host.ts +44 -0
- package/src/orchestration/index.ts +1 -0
- package/src/orchestration/recovery.ts +14 -8
- package/src/process.ts +77 -0
- package/src/rpc.ts +261 -49
- package/src/session-migration.ts +29 -5
- package/src/sessions.ts +115 -29
- package/src/subagents.ts +11 -2
- package/src/turn-replay.ts +151 -0
- package/src/ui.ts +90 -32
- package/src/verification.ts +74 -60
- package/src/workflows.ts +7 -1
|
@@ -72,6 +72,13 @@ export interface RunResult {
|
|
|
72
72
|
readonly failureCode?: string
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
/** Why one node failed, handed to {@link OrchestrationHostOptions.onStepFailed}. */
|
|
76
|
+
export interface StepFailure {
|
|
77
|
+
readonly code: "STEP_FAILED" | "GATE_REJECTED"
|
|
78
|
+
/** What the step threw (bounded), or a rejecting verify gate's string `reason`. Not masked. */
|
|
79
|
+
readonly message?: string
|
|
80
|
+
}
|
|
81
|
+
|
|
75
82
|
export interface SubmitOptions {
|
|
76
83
|
/** Caller-supplied for reproducibility; defaults to a per-host monotonic id (no clock, no random). */
|
|
77
84
|
readonly runId?: string
|
|
@@ -98,9 +105,17 @@ export interface OrchestrationHostOptions {
|
|
|
98
105
|
* running. `limits.maxAbandonedSubagents` bounds how many may run at once across all runs.
|
|
99
106
|
*/
|
|
100
107
|
onSubagentAbandoned?(runId: string, nodeId: string, abandonment: SubagentAbandonment): void
|
|
108
|
+
/**
|
|
109
|
+
* Called when a node fails, with what it threw or why a verify gate refused. Evidence and
|
|
110
|
+
* {@link RunResult} stay content-free, so this is the only place the text goes: mask anything
|
|
111
|
+
* sensitive before keeping it. A throw here is ignored.
|
|
112
|
+
*/
|
|
113
|
+
onStepFailed?(runId: string, nodeId: string, failure: StepFailure): void
|
|
101
114
|
}
|
|
102
115
|
|
|
103
116
|
const DEFAULT_MAX_STEPS = 100_000
|
|
117
|
+
// Matches the bound the workflow kernel puts on a thrown step's message.
|
|
118
|
+
const MAX_FAILURE_MESSAGE = 4096
|
|
104
119
|
const DEFAULT_MAX_DEPTH = 64
|
|
105
120
|
|
|
106
121
|
interface RunRecord {
|
|
@@ -136,6 +151,7 @@ export class OrchestrationHost {
|
|
|
136
151
|
private counter = 0
|
|
137
152
|
private readonly subagentAbandonment: SubagentAbandonmentLedger = { abandoned: 0 }
|
|
138
153
|
private readonly onSubagentAbandoned: OrchestrationHostOptions["onSubagentAbandoned"]
|
|
154
|
+
private readonly onStepFailed: OrchestrationHostOptions["onStepFailed"]
|
|
139
155
|
|
|
140
156
|
constructor(options: OrchestrationHostOptions) {
|
|
141
157
|
this.catalog = options.catalog
|
|
@@ -144,6 +160,7 @@ export class OrchestrationHost {
|
|
|
144
160
|
this.maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH
|
|
145
161
|
this.policy = options.policy
|
|
146
162
|
this.onSubagentAbandoned = options.onSubagentAbandoned
|
|
163
|
+
this.onStepFailed = options.onStepFailed
|
|
147
164
|
}
|
|
148
165
|
|
|
149
166
|
/** Subagent executors abandoned by any run of this host that are still running. */
|
|
@@ -348,6 +365,9 @@ export class OrchestrationHost {
|
|
|
348
365
|
}
|
|
349
366
|
|
|
350
367
|
private async drive(run: RunRecord): Promise<void> {
|
|
368
|
+
// One map for every layer, so a node reads what an earlier layer set. It lives only as long as
|
|
369
|
+
// this drive: values are never written into the run's evidence.
|
|
370
|
+
const values = new Map<string, unknown>()
|
|
351
371
|
try {
|
|
352
372
|
for (; run.layerIndex < run.layers.length; run.layerIndex++) {
|
|
353
373
|
while (run.state === "paused") await run.gate?.promise
|
|
@@ -355,6 +375,7 @@ export class OrchestrationHost {
|
|
|
355
375
|
const layer = run.layers[run.layerIndex] as WorkflowStep
|
|
356
376
|
const result = await new WorkflowRunner({
|
|
357
377
|
signal: run.controller.signal,
|
|
378
|
+
values,
|
|
358
379
|
maxSteps: this.maxSteps,
|
|
359
380
|
maxDepth: this.maxDepth,
|
|
360
381
|
onEvent: (event) => this.project(run, event),
|
|
@@ -397,6 +418,7 @@ export class OrchestrationHost {
|
|
|
397
418
|
if (isGateRejection(run.kindById.get(event.id), event.output)) {
|
|
398
419
|
run.failureCode = "E_GATE_REJECTED"
|
|
399
420
|
await run.store.append({ ...base, status: "failed", errorCode: "GATE_REJECTED", ...effect })
|
|
421
|
+
this.reportFailure(run.runId, event.id, "GATE_REJECTED", gateReason(event.output))
|
|
400
422
|
return
|
|
401
423
|
}
|
|
402
424
|
const refs = run.artifacts.get(event.id)
|
|
@@ -411,6 +433,21 @@ export class OrchestrationHost {
|
|
|
411
433
|
if (event.type === "step.failed") {
|
|
412
434
|
run.failureCode = "E_STEP_FAILED"
|
|
413
435
|
await run.store.append({ ...base, status: "failed", errorCode: "STEP_FAILED", ...effect })
|
|
436
|
+
this.reportFailure(run.runId, event.id, "STEP_FAILED", event.error)
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
private reportFailure(
|
|
441
|
+
runId: string,
|
|
442
|
+
nodeId: string,
|
|
443
|
+
code: StepFailure["code"],
|
|
444
|
+
message: string | undefined,
|
|
445
|
+
): void {
|
|
446
|
+
if (this.onStepFailed === undefined) return
|
|
447
|
+
try {
|
|
448
|
+
this.onStepFailed(runId, nodeId, message === undefined ? { code } : { code, message })
|
|
449
|
+
} catch {
|
|
450
|
+
// The caller's sink must not decide the run's outcome.
|
|
414
451
|
}
|
|
415
452
|
}
|
|
416
453
|
|
|
@@ -448,6 +485,13 @@ export class OrchestrationHost {
|
|
|
448
485
|
}
|
|
449
486
|
|
|
450
487
|
/** A verify/approve node whose output is not a pass. Other kinds never gate. */
|
|
488
|
+
function gateReason(output: unknown): string | undefined {
|
|
489
|
+
const reason =
|
|
490
|
+
typeof output === "object" && output !== null && "reason" in output ? output.reason : undefined
|
|
491
|
+
if (typeof reason !== "string" || reason.length === 0) return undefined
|
|
492
|
+
return reason.length > MAX_FAILURE_MESSAGE ? `${reason.slice(0, MAX_FAILURE_MESSAGE)}…` : reason
|
|
493
|
+
}
|
|
494
|
+
|
|
451
495
|
function isGateRejection(kind: RunNodeKind | undefined, output: unknown): boolean {
|
|
452
496
|
if (kind === "verify") {
|
|
453
497
|
if (output === true) return false
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import {
|
|
12
|
+
type DispatchBoundary,
|
|
12
13
|
type DispatchResultCode,
|
|
13
14
|
type InjectedClock,
|
|
14
15
|
type RunDispatchStore,
|
|
@@ -45,7 +46,11 @@ export interface CommittedEffect {
|
|
|
45
46
|
export interface RunEffectContext {
|
|
46
47
|
readonly lease: RunLease
|
|
47
48
|
readonly signal: AbortSignal
|
|
48
|
-
/**
|
|
49
|
+
/**
|
|
50
|
+
* Persist a content-free safe boundary; a failed write prevents the next unsafe step. Recovery
|
|
51
|
+
* marks the effect started before invoking it, so a worker lost mid-effect is dead-lettered
|
|
52
|
+
* unless proven; `before-effect` declares nothing has acted yet, so a redelivery may rerun it.
|
|
53
|
+
*/
|
|
49
54
|
readonly checkpoint: (boundary: "before-effect" | "after-effect") => Promise<boolean>
|
|
50
55
|
}
|
|
51
56
|
|
|
@@ -168,7 +173,11 @@ export class RunRecoveryMachine {
|
|
|
168
173
|
const inspection = await this.store.inspect(lease.dispatch.dispatchId)
|
|
169
174
|
const checkpoint = inspection?.checkpoint
|
|
170
175
|
const proof = await this.proofs.has(lease.idempotencyKey)
|
|
171
|
-
if (
|
|
176
|
+
if (
|
|
177
|
+
proof ||
|
|
178
|
+
checkpoint?.boundary === "after-effect" ||
|
|
179
|
+
checkpoint?.boundary === "effect-started"
|
|
180
|
+
) {
|
|
172
181
|
if (!proof) {
|
|
173
182
|
await this.store.deadLetter(lease, "idempotency_required")
|
|
174
183
|
return "dead-lettered"
|
|
@@ -177,8 +186,8 @@ export class RunRecoveryMachine {
|
|
|
177
186
|
return completed.ok ? "completed" : "waiting"
|
|
178
187
|
}
|
|
179
188
|
|
|
180
|
-
const
|
|
181
|
-
if (!
|
|
189
|
+
const started = await this.storeCheckpoint(lease, "effect-started")
|
|
190
|
+
if (!started) return "waiting"
|
|
182
191
|
if (signal.aborted) {
|
|
183
192
|
await this.store.cancel(lease.dispatch.runId, "cancelled")
|
|
184
193
|
return "cancelled"
|
|
@@ -229,10 +238,7 @@ export class RunRecoveryMachine {
|
|
|
229
238
|
}
|
|
230
239
|
}
|
|
231
240
|
|
|
232
|
-
private async storeCheckpoint(
|
|
233
|
-
lease: RunLease,
|
|
234
|
-
boundary: "before-effect" | "after-effect",
|
|
235
|
-
): Promise<boolean> {
|
|
241
|
+
private async storeCheckpoint(lease: RunLease, boundary: DispatchBoundary): Promise<boolean> {
|
|
236
242
|
const outcome = await this.store.checkpoint(lease, {
|
|
237
243
|
version: 1,
|
|
238
244
|
dispatchId: lease.dispatch.dispatchId,
|
package/src/process.ts
CHANGED
|
@@ -3,9 +3,11 @@ export interface BoundedText {
|
|
|
3
3
|
readonly truncated: boolean
|
|
4
4
|
}
|
|
5
5
|
|
|
6
|
+
/** Read at most `maxBytes` of `stream`; `signal` stops the read early with what arrived so far. */
|
|
6
7
|
export async function readBoundedText(
|
|
7
8
|
stream: ReadableStream<Uint8Array> | null | undefined | number,
|
|
8
9
|
maxBytes: number,
|
|
10
|
+
signal?: AbortSignal,
|
|
9
11
|
): Promise<BoundedText> {
|
|
10
12
|
if (stream === null || stream === undefined || typeof stream === "number")
|
|
11
13
|
return { text: "", truncated: false }
|
|
@@ -16,6 +18,11 @@ export async function readBoundedText(
|
|
|
16
18
|
const chunks: string[] = []
|
|
17
19
|
let total = 0
|
|
18
20
|
let truncated = false
|
|
21
|
+
const stop = (): void => {
|
|
22
|
+
reader.cancel().catch(() => {})
|
|
23
|
+
}
|
|
24
|
+
if (signal?.aborted) stop()
|
|
25
|
+
else signal?.addEventListener("abort", stop, { once: true })
|
|
19
26
|
try {
|
|
20
27
|
for (;;) {
|
|
21
28
|
const { done, value } = await reader.read()
|
|
@@ -41,7 +48,77 @@ export async function readBoundedText(
|
|
|
41
48
|
chunks.push(decoder.decode(value, { stream: true }))
|
|
42
49
|
}
|
|
43
50
|
} finally {
|
|
51
|
+
signal?.removeEventListener("abort", stop)
|
|
44
52
|
reader.releaseLock()
|
|
45
53
|
}
|
|
46
54
|
return { text: chunks.join(""), truncated }
|
|
47
55
|
}
|
|
56
|
+
|
|
57
|
+
export interface BoundedProcessOptions {
|
|
58
|
+
readonly cwd: string
|
|
59
|
+
readonly env: Record<string, string>
|
|
60
|
+
readonly timeoutMs: number
|
|
61
|
+
readonly maxStdoutBytes: number
|
|
62
|
+
readonly maxStderrBytes: number
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface BoundedProcessResult {
|
|
66
|
+
readonly stdout: BoundedText
|
|
67
|
+
readonly stderr: BoundedText
|
|
68
|
+
readonly status: number | null
|
|
69
|
+
readonly timedOut: boolean
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const KILL_GRACE_MS = 2_000
|
|
73
|
+
const PROCESS_GROUPS = process.platform !== "win32"
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Run a trusted command to completion within `timeoutMs`. On POSIX the child leads its own process
|
|
77
|
+
* group, so a timeout reaches every process it started: SIGTERM, then SIGKILL after a grace period.
|
|
78
|
+
* Once the child is gone, pipes still held open by something outside the group are no longer
|
|
79
|
+
* waited on, so the call always settles.
|
|
80
|
+
*/
|
|
81
|
+
export async function runBoundedProcess(
|
|
82
|
+
argv: readonly string[],
|
|
83
|
+
options: BoundedProcessOptions,
|
|
84
|
+
): Promise<BoundedProcessResult> {
|
|
85
|
+
const proc = Bun.spawn([...argv], {
|
|
86
|
+
cwd: options.cwd,
|
|
87
|
+
stdin: "ignore",
|
|
88
|
+
stdout: "pipe",
|
|
89
|
+
stderr: "pipe",
|
|
90
|
+
env: options.env,
|
|
91
|
+
detached: PROCESS_GROUPS,
|
|
92
|
+
})
|
|
93
|
+
const signalAll = (signal: "SIGTERM" | "SIGKILL"): void => {
|
|
94
|
+
try {
|
|
95
|
+
if (PROCESS_GROUPS) process.kill(-proc.pid, signal)
|
|
96
|
+
else proc.kill(signal)
|
|
97
|
+
} catch {
|
|
98
|
+
// The group is already gone.
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const reading = new AbortController()
|
|
102
|
+
const graceMs = Math.min(KILL_GRACE_MS, options.timeoutMs)
|
|
103
|
+
let timedOut = false
|
|
104
|
+
let escalation: ReturnType<typeof setTimeout> | undefined
|
|
105
|
+
const timer = setTimeout(() => {
|
|
106
|
+
timedOut = true
|
|
107
|
+
signalAll("SIGTERM")
|
|
108
|
+
escalation = setTimeout(() => {
|
|
109
|
+
signalAll("SIGKILL")
|
|
110
|
+
void proc.exited.then(() => reading.abort())
|
|
111
|
+
}, graceMs)
|
|
112
|
+
}, options.timeoutMs)
|
|
113
|
+
try {
|
|
114
|
+
const [stdout, stderr, status] = await Promise.all([
|
|
115
|
+
readBoundedText(proc.stdout, options.maxStdoutBytes, reading.signal),
|
|
116
|
+
readBoundedText(proc.stderr, options.maxStderrBytes, reading.signal),
|
|
117
|
+
proc.exited,
|
|
118
|
+
])
|
|
119
|
+
return { stdout, stderr, status, timedOut }
|
|
120
|
+
} finally {
|
|
121
|
+
clearTimeout(timer)
|
|
122
|
+
clearTimeout(escalation)
|
|
123
|
+
}
|
|
124
|
+
}
|