@nifrajs/coding-agent 4.0.2 → 5.1.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.
Files changed (111) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +22 -2
  3. package/dist/approvals.d.ts +30 -2
  4. package/dist/approvals.d.ts.map +1 -1
  5. package/dist/approvals.js +43 -12
  6. package/dist/approvals.js.map +1 -1
  7. package/dist/cli.d.ts +13 -0
  8. package/dist/cli.d.ts.map +1 -1
  9. package/dist/cli.js +120 -100
  10. package/dist/cli.js.map +1 -1
  11. package/dist/context.d.ts.map +1 -1
  12. package/dist/context.js +9 -21
  13. package/dist/context.js.map +1 -1
  14. package/dist/diff.d.ts.map +1 -1
  15. package/dist/diff.js +8 -21
  16. package/dist/diff.js.map +1 -1
  17. package/dist/extensions.js +4 -2
  18. package/dist/extensions.js.map +1 -1
  19. package/dist/handoffs.d.ts +5 -0
  20. package/dist/handoffs.d.ts.map +1 -1
  21. package/dist/handoffs.js +27 -0
  22. package/dist/handoffs.js.map +1 -1
  23. package/dist/host.d.ts +15 -1
  24. package/dist/host.d.ts.map +1 -1
  25. package/dist/host.js +152 -37
  26. package/dist/host.js.map +1 -1
  27. package/dist/index.d.ts +2 -2
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +2 -2
  30. package/dist/index.js.map +1 -1
  31. package/dist/isolated.js +3 -2
  32. package/dist/isolated.js.map +1 -1
  33. package/dist/native.d.ts +29 -2
  34. package/dist/native.d.ts.map +1 -1
  35. package/dist/native.js +86 -26
  36. package/dist/native.js.map +1 -1
  37. package/dist/orchestration/dispatch.d.ts +5 -1
  38. package/dist/orchestration/dispatch.d.ts.map +1 -1
  39. package/dist/orchestration/dispatch.js +1 -1
  40. package/dist/orchestration/dispatch.js.map +1 -1
  41. package/dist/orchestration/evidence-store.d.ts.map +1 -1
  42. package/dist/orchestration/evidence-store.js +5 -6
  43. package/dist/orchestration/evidence-store.js.map +1 -1
  44. package/dist/orchestration/host.d.ts +14 -0
  45. package/dist/orchestration/host.d.ts.map +1 -1
  46. package/dist/orchestration/host.js +26 -0
  47. package/dist/orchestration/host.js.map +1 -1
  48. package/dist/orchestration/index.d.ts +1 -1
  49. package/dist/orchestration/index.d.ts.map +1 -1
  50. package/dist/orchestration/index.js.map +1 -1
  51. package/dist/orchestration/recovery.d.ts +5 -1
  52. package/dist/orchestration/recovery.d.ts.map +1 -1
  53. package/dist/orchestration/recovery.js +5 -3
  54. package/dist/orchestration/recovery.js.map +1 -1
  55. package/dist/process.d.ts +22 -1
  56. package/dist/process.d.ts.map +1 -1
  57. package/dist/process.js +75 -1
  58. package/dist/process.js.map +1 -1
  59. package/dist/rpc.d.ts +29 -0
  60. package/dist/rpc.d.ts.map +1 -1
  61. package/dist/rpc.js +205 -46
  62. package/dist/rpc.js.map +1 -1
  63. package/dist/session-migration.d.ts.map +1 -1
  64. package/dist/session-migration.js +30 -6
  65. package/dist/session-migration.js.map +1 -1
  66. package/dist/sessions.d.ts +13 -2
  67. package/dist/sessions.d.ts.map +1 -1
  68. package/dist/sessions.js +104 -24
  69. package/dist/sessions.js.map +1 -1
  70. package/dist/subagents.d.ts.map +1 -1
  71. package/dist/subagents.js +13 -2
  72. package/dist/subagents.js.map +1 -1
  73. package/dist/turn-replay.d.ts +40 -0
  74. package/dist/turn-replay.d.ts.map +1 -0
  75. package/dist/turn-replay.js +136 -0
  76. package/dist/turn-replay.js.map +1 -0
  77. package/dist/ui.js +84 -30
  78. package/dist/ui.js.map +1 -1
  79. package/dist/verification.d.ts +7 -0
  80. package/dist/verification.d.ts.map +1 -1
  81. package/dist/verification.js +66 -63
  82. package/dist/verification.js.map +1 -1
  83. package/dist/workflows.d.ts +5 -0
  84. package/dist/workflows.d.ts.map +1 -1
  85. package/dist/workflows.js +2 -1
  86. package/dist/workflows.js.map +1 -1
  87. package/package.json +6 -6
  88. package/src/approvals.ts +70 -16
  89. package/src/cli.ts +125 -93
  90. package/src/context.ts +12 -20
  91. package/src/diff.ts +11 -23
  92. package/src/extensions.ts +6 -2
  93. package/src/handoffs.ts +27 -0
  94. package/src/host.ts +165 -34
  95. package/src/index.ts +3 -0
  96. package/src/isolated.ts +3 -2
  97. package/src/native.ts +137 -34
  98. package/src/orchestration/dispatch.ts +6 -2
  99. package/src/orchestration/evidence-store.ts +5 -6
  100. package/src/orchestration/host.ts +44 -0
  101. package/src/orchestration/index.ts +1 -0
  102. package/src/orchestration/recovery.ts +14 -8
  103. package/src/process.ts +89 -0
  104. package/src/rpc.ts +268 -49
  105. package/src/session-migration.ts +29 -5
  106. package/src/sessions.ts +115 -29
  107. package/src/subagents.ts +11 -2
  108. package/src/turn-replay.ts +151 -0
  109. package/src/ui.ts +90 -32
  110. package/src/verification.ts +74 -60
  111. package/src/workflows.ts +7 -1
@@ -15,7 +15,11 @@ export const DISPATCH_JOB_NAME = "nifra.agent.run-node"
15
15
  export const MAX_DISPATCH_STRING = 256
16
16
  export const MAX_DISPATCH_ATTEMPTS = 16
17
17
 
18
- export type DispatchBoundary = "before-effect" | "after-effect"
18
+ /**
19
+ * `before-effect`: no side effect has happened, so a redelivery may run the effect. `effect-started`:
20
+ * the effect was invoked and may have acted, so a redelivery needs a proof. `after-effect`: it acted.
21
+ */
22
+ export type DispatchBoundary = "before-effect" | "effect-started" | "after-effect"
19
23
  export type DispatchState =
20
24
  | "queued"
21
25
  | "leased"
@@ -300,7 +304,7 @@ export function parseRunCheckpoint(value: unknown): RunCheckpoint {
300
304
  if (input.version !== RUN_DISPATCH_VERSION)
301
305
  throw new TypeError("run checkpoint.version is unsupported")
302
306
  const boundary = input.boundary
303
- if (boundary !== "before-effect" && boundary !== "after-effect")
307
+ if (boundary !== "before-effect" && boundary !== "effect-started" && boundary !== "after-effect")
304
308
  throw new TypeError("run checkpoint.boundary is invalid")
305
309
  return Object.freeze({
306
310
  version: RUN_DISPATCH_VERSION,
@@ -10,6 +10,7 @@
10
10
  * evidence only, and the file store persists exactly the content-free record it parsed.
11
11
  */
12
12
 
13
+ import { createHash } from "node:crypto"
13
14
  import { appendFile } from "node:fs/promises"
14
15
  import type { ArtifactRef, RunEvidence } from "@nifrajs/agent-protocol"
15
16
  import { assertEvidenceSize, parseRunEvidence } from "@nifrajs/agent-protocol"
@@ -41,12 +42,10 @@ export interface EvidenceStore {
41
42
  }
42
43
 
43
44
  const DEFAULT_MAX_LIVE = 1024
44
- const encoder = new TextEncoder()
45
45
 
46
- async function sha256Bytes(text: string): Promise<Uint8Array> {
47
- const digest = await crypto.subtle.digest("SHA-256", encoder.encode(text))
48
- return new Uint8Array(digest)
49
- }
46
+ // Synchronous: `crypto.subtle` hands every record to a worker thread and back, which on a loaded
47
+ // machine made a 10k-record run take seconds.
48
+ const sha256Bytes = (text: string): Uint8Array => createHash("sha256").update(text).digest()
50
49
 
51
50
  /**
52
51
  * The identity of one record for the terminal digest: node outcome only. `seq`, `durationMs`, and
@@ -97,7 +96,7 @@ class EvidenceAccumulator {
97
96
  } else this.failedCount++
98
97
  if (record.artifacts !== undefined)
99
98
  for (const ref of record.artifacts) this.artifactRefs.push(ref)
100
- const hash = await sha256Bytes(digestKey(record))
99
+ const hash = sha256Bytes(digestKey(record))
101
100
  let carry = 0
102
101
  for (let i = 31; i >= 0; i--) {
103
102
  const sum = (this.acc[i] as number) + (hash[i] as number) + carry
@@ -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
@@ -104,6 +104,7 @@ export {
104
104
  type RunResult,
105
105
  type RunState,
106
106
  type RunStatus,
107
+ type StepFailure,
107
108
  type SubmitOptions,
108
109
  } from "./host.ts"
109
110
  export {
@@ -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
- /** Persist a content-free safe boundary; a failed write prevents the next unsafe step. */
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 (proof || checkpoint?.boundary === "after-effect") {
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 before = await this.storeCheckpoint(lease, "before-effect")
181
- if (!before) return "waiting"
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,89 @@ 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
+ // How long the pipes are still read once the child has exited, for output it wrote just before.
74
+ const DRAIN_GRACE_MS = 2_000
75
+ const PROCESS_GROUPS = process.platform !== "win32"
76
+
77
+ /**
78
+ * Run a trusted command to completion within `timeoutMs`. On POSIX the child leads its own process
79
+ * group, so a timeout reaches every process it started: SIGTERM, then SIGKILL after a grace period.
80
+ * The call settles when the child exits: a process it left running that still holds the pipes open
81
+ * is read from for a short grace and then no longer waited on, and `timedOut` stays `false`.
82
+ */
83
+ export async function runBoundedProcess(
84
+ argv: readonly string[],
85
+ options: BoundedProcessOptions,
86
+ ): Promise<BoundedProcessResult> {
87
+ const proc = Bun.spawn([...argv], {
88
+ cwd: options.cwd,
89
+ stdin: "ignore",
90
+ stdout: "pipe",
91
+ stderr: "pipe",
92
+ env: options.env,
93
+ detached: PROCESS_GROUPS,
94
+ })
95
+ const signalAll = (signal: "SIGTERM" | "SIGKILL"): void => {
96
+ try {
97
+ if (PROCESS_GROUPS) process.kill(-proc.pid, signal)
98
+ else proc.kill(signal)
99
+ } catch {
100
+ // The group is already gone.
101
+ }
102
+ }
103
+ const reading = new AbortController()
104
+ const graceMs = Math.min(KILL_GRACE_MS, options.timeoutMs)
105
+ let timedOut = false
106
+ let escalation: ReturnType<typeof setTimeout> | undefined
107
+ const timer = setTimeout(() => {
108
+ timedOut = true
109
+ signalAll("SIGTERM")
110
+ escalation = setTimeout(() => {
111
+ signalAll("SIGKILL")
112
+ void proc.exited.then(() => reading.abort())
113
+ }, graceMs)
114
+ }, options.timeoutMs)
115
+ let drain: ReturnType<typeof setTimeout> | undefined
116
+ // Awaited below, so the drain timer is armed before the `finally` that clears it can run.
117
+ const exited = proc.exited.then((status) => {
118
+ if (!timedOut) {
119
+ clearTimeout(timer)
120
+ drain = setTimeout(() => reading.abort(), DRAIN_GRACE_MS)
121
+ }
122
+ return status
123
+ })
124
+ try {
125
+ const [stdout, stderr, status] = await Promise.all([
126
+ readBoundedText(proc.stdout, options.maxStdoutBytes, reading.signal),
127
+ readBoundedText(proc.stderr, options.maxStderrBytes, reading.signal),
128
+ exited,
129
+ ])
130
+ return { stdout, stderr, status, timedOut }
131
+ } finally {
132
+ clearTimeout(timer)
133
+ clearTimeout(escalation)
134
+ clearTimeout(drain)
135
+ }
136
+ }