@cat-factory/executor-harness 1.94.0 → 1.98.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 +16 -12
- package/dist/agent-capabilities.d.ts +61 -0
- package/dist/agent-capabilities.js +113 -0
- package/dist/agent-runner.d.ts +30 -2
- package/dist/agent-runner.js +146 -47
- package/dist/bootstrap-mode.js +1 -0
- package/dist/coding-agent.d.ts +2 -1
- package/dist/embed.d.ts +2 -1
- package/dist/embed.js +2 -1
- package/dist/failure.d.ts +19 -1
- package/dist/failure.js +40 -0
- package/dist/git.d.ts +6 -0
- package/dist/git.js +16 -9
- package/dist/inline.d.ts +6 -0
- package/dist/inline.js +6 -0
- package/dist/job.d.ts +2 -1
- package/dist/jsonl-stream.d.ts +70 -0
- package/dist/jsonl-stream.js +149 -0
- package/dist/pi-reduction.d.ts +136 -0
- package/dist/pi-reduction.js +303 -0
- package/dist/pi-workspace.d.ts +2 -1
- package/dist/pi-workspace.js +11 -1
- package/dist/pi.d.ts +8 -81
- package/dist/pi.js +124 -310
- package/dist/runner.d.ts +53 -0
- package/dist/runner.js +53 -3
- package/dist/structured-output.js +2 -1
- package/dist/tool-silence.d.ts +74 -0
- package/dist/tool-silence.js +99 -0
- package/package.json +4 -4
- package/src/agent-capabilities.ts +163 -0
- package/src/agent-runner.ts +185 -47
- package/src/agent.ts +1 -1
- package/src/bootstrap-mode.ts +2 -1
- package/src/coding-agent.ts +2 -1
- package/src/embed.ts +8 -5
- package/src/failure.ts +36 -9
- package/src/git.ts +17 -9
- package/src/inline.ts +6 -0
- package/src/job.ts +2 -1
- package/src/jsonl-stream.ts +149 -0
- package/src/pi-reduction.ts +359 -0
- package/src/pi-workspace.ts +12 -3
- package/src/pi.ts +144 -349
- package/src/runner.ts +116 -4
- package/src/structured-output.ts +2 -1
- package/src/tool-silence.ts +125 -0
package/src/runner.ts
CHANGED
|
@@ -3,13 +3,16 @@ import type { FollowUpLine } from './follow-ups.js'
|
|
|
3
3
|
import type { ValidationReport } from './validation-checks.js'
|
|
4
4
|
import type { ReproductionReport } from './reproduction-proof.js'
|
|
5
5
|
import type { SliceReview } from './subagents.js'
|
|
6
|
+
import type { ObservedMcpServer } from './agent-capabilities.js'
|
|
6
7
|
import type { HarnessCallMetric, TodoProgress, ToolSpan } from './pi.js'
|
|
7
8
|
import { log, type Logger } from './logger.js'
|
|
9
|
+
import { ToolSilenceWatchdog, type ToolProgressWindow } from './tool-silence.js'
|
|
8
10
|
import {
|
|
9
11
|
type FailureCause,
|
|
10
12
|
failureCauseOf,
|
|
11
13
|
inactivityAbortMessage,
|
|
12
14
|
maxDurationAbortMessage,
|
|
15
|
+
toolSilenceAbortMessage,
|
|
13
16
|
} from './failure.js'
|
|
14
17
|
|
|
15
18
|
/** Non-secret correlation fields a job carries on every log line (jobId, repo, branch, …). */
|
|
@@ -31,6 +34,22 @@ export interface RunOptions {
|
|
|
31
34
|
onProgress?: (progress: TodoProgress) => void
|
|
32
35
|
/** Receives one compact {@link ToolSpan} per completed tool call (observability). */
|
|
33
36
|
onSpan?: (span: ToolSpan) => void
|
|
37
|
+
/**
|
|
38
|
+
* Opens the tool-silence window (stuck-run audit F13) for ONE agent stream, returning the
|
|
39
|
+
* handle that stream beats on every completed tool call and closes when it ends.
|
|
40
|
+
*
|
|
41
|
+
* Called by the agent-CLI runners themselves rather than by the phase marker, because the
|
|
42
|
+
* window is only meaningful while something able to RESET it is running and only the runner
|
|
43
|
+
* knows whether its CLI reports completed tool calls at all. A caller that runs no tool loop
|
|
44
|
+
* (the inline one-shot completion) simply does not forward this, which is a statement, not an
|
|
45
|
+
* omission: the run stays bounded by the inactivity and max-duration watchdogs, and a window
|
|
46
|
+
* nothing could ever beat would only be able to expire.
|
|
47
|
+
*
|
|
48
|
+
* Absent ⇒ no window is opened and this watchdog is silent for that work. It fails toward NOT
|
|
49
|
+
* killing on purpose: this audit exists as much to stop recovery machinery ending healthy runs
|
|
50
|
+
* as to bound wedged ones, and the wall-clock cap is underneath either way.
|
|
51
|
+
*/
|
|
52
|
+
beginToolWindow?: () => ToolProgressWindow
|
|
34
53
|
/** Receives the forward-looking follow-up / question items the Coder streamed since the last poll. */
|
|
35
54
|
onFollowUp?: (items: FollowUpLine[]) => void
|
|
36
55
|
/**
|
|
@@ -56,6 +75,15 @@ export interface RunOptions {
|
|
|
56
75
|
* already persisted. Absent for a job that dispatched no subagents.
|
|
57
76
|
*/
|
|
58
77
|
onSliceReviews?: (reviews: SliceReview[]) => void
|
|
78
|
+
/**
|
|
79
|
+
* Receives what the agent's CLI reported about the tool servers (MCP) it loaded, once it
|
|
80
|
+
* announces its resolved session. Latest-wins (NOT a drain buffer) for the same reason as
|
|
81
|
+
* {@link onValidationReport}, with an extra one of its own: the CLI announces the set ONCE,
|
|
82
|
+
* near the start of the run, so a drain buffer would hand it to whichever poll happened to
|
|
83
|
+
* land next and lose it entirely if that poll response were dropped — on the single fact this
|
|
84
|
+
* whole channel exists to carry. Absent for a job that wired no tool servers.
|
|
85
|
+
*/
|
|
86
|
+
onToolServers?: (observed: ObservedMcpServer[]) => void
|
|
59
87
|
/**
|
|
60
88
|
* Receives each per-call telemetry row the moment the agent's CLI stream yields it, so a
|
|
61
89
|
* run's model calls reach `llm_call_metrics` WHILE it runs rather than only in its terminal
|
|
@@ -224,6 +252,18 @@ export interface JobView<TResult extends JobResultBase = JobResultBase> {
|
|
|
224
252
|
* from. Absent for a job that dispatched no subagents.
|
|
225
253
|
*/
|
|
226
254
|
sliceReviews?: SliceReview[]
|
|
255
|
+
/**
|
|
256
|
+
* What the agent's CLI reported about the tool servers (MCP) wired for this job when it started
|
|
257
|
+
* up: per server, the status the CLI gave it and how many tools it contributed. A whole-value
|
|
258
|
+
* latest publish like {@link validationReport}, not drain-on-read — the CLI announces this once
|
|
259
|
+
* and every later poll re-reports the same set, so no poll can be the one that loses it.
|
|
260
|
+
*
|
|
261
|
+
* The complement of what the BACKEND recorded at dispatch, and the only source for the half it
|
|
262
|
+
* cannot see: the dispatch record says why the platform withheld a tool, this says a wired
|
|
263
|
+
* server failed to start anyway. Absent for a job that wired none, and for a harness whose CLI
|
|
264
|
+
* reports nothing — which is why it is absent rather than empty (see `ObservedMcpServer`).
|
|
265
|
+
*/
|
|
266
|
+
toolServers?: ObservedMcpServer[]
|
|
227
267
|
}
|
|
228
268
|
|
|
229
269
|
interface JobEntry<TResult extends JobResultBase> extends JobView<TResult> {
|
|
@@ -259,6 +299,20 @@ export interface RunnerLimits {
|
|
|
259
299
|
* progress, which counts as activity). Set to 0 to disable.
|
|
260
300
|
*/
|
|
261
301
|
coldStartMs: number
|
|
302
|
+
/**
|
|
303
|
+
* Stuck-run audit F13: force-fail the job if a running agent stream completes no tool call for
|
|
304
|
+
* this long. The gap the other two watchdogs structurally cannot see — a model that keeps
|
|
305
|
+
* talking (or thinking out loud) resets the inactivity timer on every chunk while completing
|
|
306
|
+
* nothing, so the only remaining bound was the full wall-clock cap and the engine's
|
|
307
|
+
* ~70-minute poll budget behind it.
|
|
308
|
+
*
|
|
309
|
+
* Armed only for as long as a stream that REPORTS completed tool calls is running (see
|
|
310
|
+
* {@link RunOptions.beginToolWindow}), so the activity-silent stretches — clone, dependency
|
|
311
|
+
* install, push, a validation loop's check commands — are outside it by construction: they
|
|
312
|
+
* legitimately complete no tool calls, and they are bounded by their own per-command timeouts.
|
|
313
|
+
* Set to 0 to disable.
|
|
314
|
+
*/
|
|
315
|
+
toolSilenceMs: number
|
|
262
316
|
}
|
|
263
317
|
|
|
264
318
|
function intEnv(value: string | undefined, fallback: number): number {
|
|
@@ -274,23 +328,48 @@ function intEnvAllowZero(value: string | undefined, fallback: number): number {
|
|
|
274
328
|
}
|
|
275
329
|
|
|
276
330
|
export function loadRunnerLimits(env: NodeJS.ProcessEnv = process.env): RunnerLimits {
|
|
331
|
+
const maxDurationMs = intEnv(env.JOB_MAX_DURATION_MS, 60 * 60_000)
|
|
332
|
+
const inactivityMs = intEnv(env.JOB_INACTIVITY_MS, 10 * 60_000)
|
|
277
333
|
return {
|
|
278
334
|
// 60 minutes: generous headroom for serious multi-file coding tasks while
|
|
279
335
|
// still bounding a runaway container.
|
|
280
|
-
maxDurationMs
|
|
336
|
+
maxDurationMs,
|
|
281
337
|
// 10 minutes of zero output is treated as hung (a single long LLM/tool call
|
|
282
338
|
// is far shorter; Pi streams events as it works). The per-git command ceiling
|
|
283
339
|
// (`GIT_TIMEOUT_MS` in git.ts) is DERIVED from this value — a fixed margin below
|
|
284
340
|
// it — so a slow clone/push (which emits no activity events) always times out
|
|
285
341
|
// with git's own clear reason rather than this watchdog's "likely hung" message,
|
|
286
342
|
// for any configured window. See the invariant note in git.ts.
|
|
287
|
-
inactivityMs
|
|
343
|
+
inactivityMs,
|
|
288
344
|
// 2 minutes: comfortably longer than a warm agent's time-to-first-token yet far
|
|
289
345
|
// under the 10-minute inactivity kill, so a truly output-less start is flagged early.
|
|
290
346
|
coldStartMs: intEnvAllowZero(env.JOB_COLD_START_MS, 2 * 60_000),
|
|
347
|
+
toolSilenceMs: intEnvAllowZero(
|
|
348
|
+
env.JOB_TOOL_SILENCE_MS,
|
|
349
|
+
toolSilenceDefault(maxDurationMs, inactivityMs),
|
|
350
|
+
),
|
|
291
351
|
}
|
|
292
352
|
}
|
|
293
353
|
|
|
354
|
+
/**
|
|
355
|
+
* The tool-silence window when the operator has not set one: HALF the configured wall-clock cap,
|
|
356
|
+
* DERIVED rather than a constant so lowering `JOB_MAX_DURATION_MS` tightens it too (a fixed
|
|
357
|
+
* 30 minutes would sit past the whole budget of a deployment that runs 20-minute jobs, i.e. be
|
|
358
|
+
* silently disabled).
|
|
359
|
+
*
|
|
360
|
+
* Floored at the inactivity window so the default never races the gone-quiet diagnostic more
|
|
361
|
+
* often than it has to. The floor is a sizing choice, NOT the thing that keeps the two watchdogs
|
|
362
|
+
* apart: they anchor on different events (the last completed tool call vs the last byte of
|
|
363
|
+
* output), so the tool-silence anchor is always the earlier of the two and equal windows would
|
|
364
|
+
* still have it firing first. What actually keeps this watchdog off a hang is the expiry test in
|
|
365
|
+
* {@link ToolSilenceWatchdog}, which fires only when output arrived DURING the window that
|
|
366
|
+
* elapsed — and which also holds for an operator who sets `JOB_TOOL_SILENCE_MS` below
|
|
367
|
+
* `JOB_INACTIVITY_MS`, where no default-side clamp applies at all.
|
|
368
|
+
*/
|
|
369
|
+
function toolSilenceDefault(maxDurationMs: number, inactivityMs: number): number {
|
|
370
|
+
return Math.max(Math.round(maxDurationMs / 2), inactivityMs)
|
|
371
|
+
}
|
|
372
|
+
|
|
294
373
|
function toView<TResult extends JobResultBase>(entry: JobEntry<TResult>): JobView<TResult> {
|
|
295
374
|
const {
|
|
296
375
|
promise: _promise,
|
|
@@ -453,7 +532,7 @@ export class JobRegistry<TJob = unknown, TResult extends JobResultBase = JobResu
|
|
|
453
532
|
|
|
454
533
|
private async drive(entry: JobEntry<TResult>, job: TJob): Promise<void> {
|
|
455
534
|
const controller = new AbortController()
|
|
456
|
-
let killReason: 'inactivity' | 'max-duration' | undefined
|
|
535
|
+
let killReason: 'inactivity' | 'max-duration' | 'no-tool-progress' | undefined
|
|
457
536
|
|
|
458
537
|
const jobLog = log.child({ jobId: entry.id, ...this.describe(job) })
|
|
459
538
|
|
|
@@ -497,6 +576,21 @@ export class JobRegistry<TJob = unknown, TResult extends JobResultBase = JobResu
|
|
|
497
576
|
// that never got going at all.
|
|
498
577
|
let lastActivityAt: number | undefined
|
|
499
578
|
|
|
579
|
+
// Stuck-run audit F13: the third watchdog, and the only one that can see a model which keeps
|
|
580
|
+
// TALKING while completing nothing — its output resets the inactivity timer on every chunk,
|
|
581
|
+
// and it is nowhere near the wall-clock cap. It is armed by the agent stream itself rather
|
|
582
|
+
// than by the phase marker (see `RunOptions.beginToolWindow`) and reads `lastActivityAt` at
|
|
583
|
+
// expiry, which is what keeps the gone-quiet case with the inactivity watchdog that owns it.
|
|
584
|
+
const toolSilence = new ToolSilenceWatchdog({
|
|
585
|
+
windowMs: this.limits.toolSilenceMs,
|
|
586
|
+
lastActivityAt: () => lastActivityAt,
|
|
587
|
+
onExpired: () => {
|
|
588
|
+
// First watchdog to fire wins the reason (see `resetInactivity` above).
|
|
589
|
+
killReason ??= 'no-tool-progress'
|
|
590
|
+
controller.abort(new Error('no tool progress'))
|
|
591
|
+
},
|
|
592
|
+
})
|
|
593
|
+
|
|
500
594
|
// ADR 0026 D4: a one-shot cold-start watchdog. If the job produces no activity within
|
|
501
595
|
// `coldStartMs`, record a structured diagnostic (a likely onboarding/auth wedge) so it
|
|
502
596
|
// is legible early — it does NOT abort the run (the inactivity watchdog still owns
|
|
@@ -534,6 +628,7 @@ export class JobRegistry<TJob = unknown, TResult extends JobResultBase = JobResu
|
|
|
534
628
|
entry.spanBuffer.push(span)
|
|
535
629
|
lastTool = { name: span.tool, at: span.endedAt }
|
|
536
630
|
},
|
|
631
|
+
beginToolWindow: () => toolSilence.open(),
|
|
537
632
|
onFollowUp: (items) => {
|
|
538
633
|
entry.followUpBuffer.push(...items)
|
|
539
634
|
},
|
|
@@ -543,6 +638,9 @@ export class JobRegistry<TJob = unknown, TResult extends JobResultBase = JobResu
|
|
|
543
638
|
onSliceReviews: (reviews) => {
|
|
544
639
|
entry.sliceReviews = reviews
|
|
545
640
|
},
|
|
641
|
+
onToolServers: (observed) => {
|
|
642
|
+
entry.toolServers = observed
|
|
643
|
+
},
|
|
546
644
|
onReproductionProof: (report) => {
|
|
547
645
|
entry.reproductionReport = report
|
|
548
646
|
},
|
|
@@ -605,6 +703,7 @@ export class JobRegistry<TJob = unknown, TResult extends JobResultBase = JobResu
|
|
|
605
703
|
clearTimeout(inactivity)
|
|
606
704
|
clearTimeout(cap)
|
|
607
705
|
clearTimeout(coldStart)
|
|
706
|
+
toolSilence.stop()
|
|
608
707
|
entry.abort = undefined
|
|
609
708
|
entry.heartbeatAt = Date.now()
|
|
610
709
|
}
|
|
@@ -654,6 +753,19 @@ export class JobRegistry<TJob = unknown, TResult extends JobResultBase = JobResu
|
|
|
654
753
|
),
|
|
655
754
|
}
|
|
656
755
|
}
|
|
756
|
+
if (ctx.killReason === 'no-tool-progress') {
|
|
757
|
+
return {
|
|
758
|
+
// The breadcrumb carries the last completed tool, which is the whole diagnostic here:
|
|
759
|
+
// it names what the agent was doing when it stopped doing anything.
|
|
760
|
+
message: redactSecrets(
|
|
761
|
+
`${toolSilenceAbortMessage(this.limits.toolSilenceMs)} (${breadcrumb})`,
|
|
762
|
+
),
|
|
763
|
+
cause: 'no-tool-progress',
|
|
764
|
+
detail: redactSecrets(
|
|
765
|
+
`Phase timings: ${phaseBreakdown || '(none)'}. ${breadcrumb}.${cold}`,
|
|
766
|
+
),
|
|
767
|
+
}
|
|
768
|
+
}
|
|
657
769
|
const raw = ctx.error instanceof Error ? ctx.error.message : String(ctx.error)
|
|
658
770
|
// A thrown error tagged with a structured cause (a git op / an upstream API call) keeps
|
|
659
771
|
// it; an untagged throw is a generic agent failure.
|
|
@@ -674,7 +786,7 @@ export class JobRegistry<TJob = unknown, TResult extends JobResultBase = JobResu
|
|
|
674
786
|
*/
|
|
675
787
|
interface FailureContext {
|
|
676
788
|
/** Which watchdog killed it; unset when the run threw on its own. */
|
|
677
|
-
killReason: 'inactivity' | 'max-duration' | undefined
|
|
789
|
+
killReason: 'inactivity' | 'max-duration' | 'no-tool-progress' | undefined
|
|
678
790
|
error: unknown
|
|
679
791
|
/** The phase the job was IN when it failed (captured before the `failed` transition). */
|
|
680
792
|
phase: string
|
package/src/structured-output.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { redact, redactSecrets, secretsToRedact } from './redact.js'
|
|
2
2
|
import { log } from './logger.js'
|
|
3
|
-
import {
|
|
3
|
+
import { phasedProxyBaseUrl } from './pi.js'
|
|
4
|
+
import { PI_MAX_OUTPUT_TOKENS } from './pi-reduction.js'
|
|
4
5
|
|
|
5
6
|
// A reusable abstraction for the "agent returns a structured JSON document as its
|
|
6
7
|
// final assistant message" pattern (requirements, blueprint, merger — and any future
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// The tool-silence watchdog (stuck-run audit F13): the third bound on a job, and the only one
|
|
2
|
+
// that can see a model which keeps TALKING while completing nothing. Its output resets the
|
|
3
|
+
// inactivity timer on every chunk, and it is nowhere near the wall-clock cap.
|
|
4
|
+
//
|
|
5
|
+
// WHY THIS IS ITS OWN MODULE — the window is only meaningful while something that REPORTS
|
|
6
|
+
// completed tool calls is running, and only that producer knows whether it does. Keying the
|
|
7
|
+
// window on the job's coarse phase label instead looked equivalent and was not: `agent` is a
|
|
8
|
+
// telemetry breadcrumb several call sites mark for several different things (a Codex pass, a
|
|
9
|
+
// tool-less inline completion, the label restored around a repair loop's shell commands), so a
|
|
10
|
+
// phase-armed window spent most of its time armed over work that could not possibly reset it.
|
|
11
|
+
// The producer opens its own window instead, which makes "armed" and "can beat" the same fact by
|
|
12
|
+
// construction rather than by two call sites agreeing.
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The live window for ONE tool-reporting agent stream. Opened by the stream, beaten by each
|
|
16
|
+
* completed tool call, closed when the stream ends (cleanly or not) — so the window can never
|
|
17
|
+
* outlive the only thing able to reset it.
|
|
18
|
+
*/
|
|
19
|
+
export interface ToolProgressWindow {
|
|
20
|
+
/** A tool call completed: the only evidence this watchdog accepts as progress. */
|
|
21
|
+
toolCompleted(): void
|
|
22
|
+
/** The stream ended; the window closes with it. Idempotent. */
|
|
23
|
+
close(): void
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A window that measures nothing: what the watchdog hands out when disabled, and what a producer
|
|
28
|
+
* substitutes when its caller wired no watchdog at all. Having one means every producer holds a
|
|
29
|
+
* real window and states its tool progress unconditionally, rather than guarding each call site
|
|
30
|
+
* with a `?.` that reads as though beating the window were optional.
|
|
31
|
+
*/
|
|
32
|
+
export const NO_TOOL_WINDOW: ToolProgressWindow = { toolCompleted: () => {}, close: () => {} }
|
|
33
|
+
|
|
34
|
+
export interface ToolSilenceDeps {
|
|
35
|
+
/** The window length. `<= 0` disables the watchdog entirely (every window is inert). */
|
|
36
|
+
windowMs: number
|
|
37
|
+
/**
|
|
38
|
+
* When the run last produced ANY output, or undefined if it never has — the same clock the
|
|
39
|
+
* inactivity watchdog resets on. Read at EXPIRY (see {@link ToolSilenceWatchdog.open}), which
|
|
40
|
+
* is what keeps this watchdog off the gone-quiet case.
|
|
41
|
+
*/
|
|
42
|
+
lastActivityAt: () => number | undefined
|
|
43
|
+
/** Called when a window expires with the run demonstrably still talking. */
|
|
44
|
+
onExpired: () => void
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Hands out {@link ToolProgressWindow}s and fires `onExpired` for one that goes a full window
|
|
49
|
+
* without a completed tool call while the run keeps producing output.
|
|
50
|
+
*
|
|
51
|
+
* ONE window is open at a time (a job runs one agent stream at a time, and a repair loop runs
|
|
52
|
+
* them in sequence). Opening a second supersedes the first, and a superseded handle's calls are
|
|
53
|
+
* ignored rather than reaching back into the live window — a stale `close()` from a stream that
|
|
54
|
+
* finished after its successor started would otherwise disarm the watchdog for the rest of the job.
|
|
55
|
+
*/
|
|
56
|
+
export class ToolSilenceWatchdog {
|
|
57
|
+
private timer: ReturnType<typeof setTimeout> | undefined
|
|
58
|
+
/** Identity of the window currently open; every handout takes the next number. */
|
|
59
|
+
private openId = 0
|
|
60
|
+
/** When the live window was last armed — the start of the span `onExpired` is a verdict about. */
|
|
61
|
+
private armedAt = 0
|
|
62
|
+
|
|
63
|
+
constructor(private readonly deps: ToolSilenceDeps) {}
|
|
64
|
+
|
|
65
|
+
/** Open a window for one agent stream. Returns an inert handle when the watchdog is disabled. */
|
|
66
|
+
open(): ToolProgressWindow {
|
|
67
|
+
if (this.deps.windowMs <= 0) return NO_TOOL_WINDOW
|
|
68
|
+
const id = ++this.openId
|
|
69
|
+
this.arm()
|
|
70
|
+
const live = (): boolean => this.openId === id
|
|
71
|
+
return {
|
|
72
|
+
toolCompleted: () => {
|
|
73
|
+
if (live()) this.arm()
|
|
74
|
+
},
|
|
75
|
+
close: () => {
|
|
76
|
+
if (!live()) return
|
|
77
|
+
// Retire the id as well as the timer, so a late `toolCompleted()` from this stream
|
|
78
|
+
// cannot re-arm a window whose producer has already gone.
|
|
79
|
+
this.openId++
|
|
80
|
+
this.stop()
|
|
81
|
+
},
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Disarm for good (the job settled). Safe to call with no window open. */
|
|
86
|
+
stop(): void {
|
|
87
|
+
clearTimeout(this.timer)
|
|
88
|
+
this.timer = undefined
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private arm(): void {
|
|
92
|
+
clearTimeout(this.timer)
|
|
93
|
+
this.armedAt = Date.now()
|
|
94
|
+
this.timer = setTimeout(() => this.expire(), this.deps.windowMs)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* A window elapsed with no completed tool call. Fire ONLY if the run was talking through it.
|
|
99
|
+
*
|
|
100
|
+
* `no-tool-progress` claims something specific — output arrived, but nothing got done — and
|
|
101
|
+
* that is only a truthful reading when output actually arrived DURING the window that just
|
|
102
|
+
* expired. A window that passed in total silence is the INACTIVITY watchdog's fact, whose
|
|
103
|
+
* diagnostic ("the container went quiet") is the one an operator can act on; relabelling it as
|
|
104
|
+
* a rabbit-hole would send them looking at the model instead of at the hang.
|
|
105
|
+
*
|
|
106
|
+
* This is a structural guard, not a tie-breaker: the two timers anchor on different events (the
|
|
107
|
+
* last tool call vs the last byte of output), so no arithmetic between the two window LENGTHS
|
|
108
|
+
* can order them. Equal windows put the tool-silence anchor strictly earlier — it fires first —
|
|
109
|
+
* and an operator setting `JOB_TOOL_SILENCE_MS` below `JOB_INACTIVITY_MS` gets that on every
|
|
110
|
+
* quiet run. Deciding from what the expired window actually SAW is independent of both numbers.
|
|
111
|
+
*
|
|
112
|
+
* A deferred window re-arms rather than standing down, so a run that goes quiet and then
|
|
113
|
+
* resumes its monologue is still caught, one full window later. The deferral terminates:
|
|
114
|
+
* either output resumes (the next expiry has output in its window and fires) or it does not
|
|
115
|
+
* (inactivity fires).
|
|
116
|
+
*/
|
|
117
|
+
private expire(): void {
|
|
118
|
+
const lastActivityAt = this.deps.lastActivityAt()
|
|
119
|
+
if (lastActivityAt === undefined || lastActivityAt <= this.armedAt) {
|
|
120
|
+
this.arm()
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
this.deps.onExpired()
|
|
124
|
+
}
|
|
125
|
+
}
|