@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.
Files changed (99) hide show
  1. package/README.md +14 -2
  2. package/dist/cli.d.ts +13 -0
  3. package/dist/cli.d.ts.map +1 -1
  4. package/dist/cli.js +120 -100
  5. package/dist/cli.js.map +1 -1
  6. package/dist/context.d.ts.map +1 -1
  7. package/dist/context.js +9 -21
  8. package/dist/context.js.map +1 -1
  9. package/dist/diff.d.ts.map +1 -1
  10. package/dist/diff.js +8 -21
  11. package/dist/diff.js.map +1 -1
  12. package/dist/handoffs.d.ts +5 -0
  13. package/dist/handoffs.d.ts.map +1 -1
  14. package/dist/handoffs.js +27 -0
  15. package/dist/handoffs.js.map +1 -1
  16. package/dist/host.d.ts +15 -1
  17. package/dist/host.d.ts.map +1 -1
  18. package/dist/host.js +117 -13
  19. package/dist/host.js.map +1 -1
  20. package/dist/index.d.ts +1 -1
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +1 -1
  23. package/dist/index.js.map +1 -1
  24. package/dist/native.d.ts +22 -0
  25. package/dist/native.d.ts.map +1 -1
  26. package/dist/native.js +75 -18
  27. package/dist/native.js.map +1 -1
  28. package/dist/orchestration/dispatch.d.ts +5 -1
  29. package/dist/orchestration/dispatch.d.ts.map +1 -1
  30. package/dist/orchestration/dispatch.js +1 -1
  31. package/dist/orchestration/dispatch.js.map +1 -1
  32. package/dist/orchestration/evidence-store.d.ts.map +1 -1
  33. package/dist/orchestration/evidence-store.js +5 -6
  34. package/dist/orchestration/evidence-store.js.map +1 -1
  35. package/dist/orchestration/host.d.ts +14 -0
  36. package/dist/orchestration/host.d.ts.map +1 -1
  37. package/dist/orchestration/host.js +26 -0
  38. package/dist/orchestration/host.js.map +1 -1
  39. package/dist/orchestration/index.d.ts +1 -1
  40. package/dist/orchestration/index.d.ts.map +1 -1
  41. package/dist/orchestration/index.js.map +1 -1
  42. package/dist/orchestration/recovery.d.ts +5 -1
  43. package/dist/orchestration/recovery.d.ts.map +1 -1
  44. package/dist/orchestration/recovery.js +5 -3
  45. package/dist/orchestration/recovery.js.map +1 -1
  46. package/dist/process.d.ts +22 -1
  47. package/dist/process.d.ts.map +1 -1
  48. package/dist/process.js +63 -1
  49. package/dist/process.js.map +1 -1
  50. package/dist/rpc.d.ts +23 -0
  51. package/dist/rpc.d.ts.map +1 -1
  52. package/dist/rpc.js +204 -46
  53. package/dist/rpc.js.map +1 -1
  54. package/dist/session-migration.d.ts.map +1 -1
  55. package/dist/session-migration.js +30 -6
  56. package/dist/session-migration.js.map +1 -1
  57. package/dist/sessions.d.ts +13 -2
  58. package/dist/sessions.d.ts.map +1 -1
  59. package/dist/sessions.js +104 -24
  60. package/dist/sessions.js.map +1 -1
  61. package/dist/subagents.d.ts.map +1 -1
  62. package/dist/subagents.js +13 -2
  63. package/dist/subagents.js.map +1 -1
  64. package/dist/turn-replay.d.ts +40 -0
  65. package/dist/turn-replay.d.ts.map +1 -0
  66. package/dist/turn-replay.js +136 -0
  67. package/dist/turn-replay.js.map +1 -0
  68. package/dist/ui.js +84 -30
  69. package/dist/ui.js.map +1 -1
  70. package/dist/verification.d.ts +7 -0
  71. package/dist/verification.d.ts.map +1 -1
  72. package/dist/verification.js +66 -63
  73. package/dist/verification.js.map +1 -1
  74. package/dist/workflows.d.ts +5 -0
  75. package/dist/workflows.d.ts.map +1 -1
  76. package/dist/workflows.js +2 -1
  77. package/dist/workflows.js.map +1 -1
  78. package/package.json +6 -6
  79. package/src/cli.ts +125 -93
  80. package/src/context.ts +12 -20
  81. package/src/diff.ts +11 -23
  82. package/src/handoffs.ts +27 -0
  83. package/src/host.ts +134 -12
  84. package/src/index.ts +1 -0
  85. package/src/native.ts +118 -21
  86. package/src/orchestration/dispatch.ts +6 -2
  87. package/src/orchestration/evidence-store.ts +5 -6
  88. package/src/orchestration/host.ts +44 -0
  89. package/src/orchestration/index.ts +1 -0
  90. package/src/orchestration/recovery.ts +14 -8
  91. package/src/process.ts +77 -0
  92. package/src/rpc.ts +261 -49
  93. package/src/session-migration.ts +29 -5
  94. package/src/sessions.ts +115 -29
  95. package/src/subagents.ts +11 -2
  96. package/src/turn-replay.ts +151 -0
  97. package/src/ui.ts +90 -32
  98. package/src/verification.ts +74 -60
  99. 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
@@ -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,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
+ }