@jini-ai/daemon 0.6.7 → 0.6.9

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 (73) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/dist/agent-executor/antigravity-permissions.d.ts +11 -0
  3. package/dist/agent-executor/antigravity-permissions.js +64 -0
  4. package/dist/agent-executor/contracts.d.ts +31 -6
  5. package/dist/agent-executor/event-translation.d.ts +6 -1
  6. package/dist/agent-executor/event-translation.js +1 -1
  7. package/dist/agent-executor/events.d.ts +1 -0
  8. package/dist/agent-executor/events.js +85 -74
  9. package/dist/agent-executor/launch.d.ts +4 -2
  10. package/dist/agent-executor/launch.js +306 -217
  11. package/dist/agent-executor/mcp-bridge-probe.js +51 -35
  12. package/dist/agent-executor/mcp-bridge.d.ts +18 -8
  13. package/dist/agent-executor/mcp-bridge.js +105 -65
  14. package/dist/agent-executor/run-output.d.ts +20 -0
  15. package/dist/agent-executor/run-output.js +80 -0
  16. package/dist/attachment-content.js +18 -15
  17. package/dist/core/read-only-tools.d.ts +26 -3
  18. package/dist/core/read-only-tools.js +38 -5
  19. package/dist/core/tool-audit.d.ts +3 -1
  20. package/dist/core/tool-audit.js +35 -22
  21. package/dist/core/tool-candidates.d.ts +8 -0
  22. package/dist/core/tool-candidates.js +6 -0
  23. package/dist/core/tool-gaps.d.ts +60 -0
  24. package/dist/core/tool-gaps.js +199 -0
  25. package/dist/delegated-tool-bridge.d.ts +1 -1
  26. package/dist/delegated-tool-bridge.js +161 -122
  27. package/dist/event-log.d.ts +45 -2
  28. package/dist/event-log.js +83 -4
  29. package/dist/http/attachments.d.ts +26 -1
  30. package/dist/http/attachments.js +129 -65
  31. package/dist/http/connectors.d.ts +1 -32
  32. package/dist/http/connectors.js +13 -68
  33. package/dist/http/delegated-tools.d.ts +5 -1
  34. package/dist/http/delegated-tools.js +29 -29
  35. package/dist/http/host-tools.d.ts +1 -0
  36. package/dist/http/host-tools.js +64 -59
  37. package/dist/http/index.d.ts +2 -2
  38. package/dist/http/index.js +1 -1
  39. package/dist/http/media.js +48 -69
  40. package/dist/http/memory.js +45 -36
  41. package/dist/http/model-proxy-fields.d.ts +23 -0
  42. package/dist/http/model-proxy-fields.js +29 -0
  43. package/dist/http/model-proxy.js +193 -395
  44. package/dist/http/research-tavily.adapter.js +72 -49
  45. package/dist/http/research.js +33 -23
  46. package/dist/http/resource-request.d.ts +37 -0
  47. package/dist/http/resource-request.js +59 -0
  48. package/dist/http/routines.js +49 -90
  49. package/dist/http/runs.d.ts +7 -5
  50. package/dist/http/runs.js +21 -7
  51. package/dist/http/terminals.js +23 -48
  52. package/dist/http/tool-catalog.d.ts +5 -0
  53. package/dist/http/xai.d.ts +11 -1
  54. package/dist/http/xai.js +125 -124
  55. package/dist/legacy-data-migration.js +41 -24
  56. package/dist/run/core/retry.js +51 -35
  57. package/dist/run/diagnostics/diagnostics.js +5 -17
  58. package/dist/run-credentials.js +49 -27
  59. package/dist/run-lifecycle.d.ts +12 -2
  60. package/dist/run-lifecycle.js +47 -26
  61. package/dist/store/agent-sessions/sqlite.d.ts +6 -0
  62. package/dist/store/agent-sessions/sqlite.js +6 -4
  63. package/dist/surface-exchanges.js +2 -1
  64. package/dist/terminal-session.d.ts +1 -1
  65. package/dist/terminal-session.js +26 -33
  66. package/dist/tool-audit.d.ts +1 -0
  67. package/dist/tool-audit.js +1 -0
  68. package/dist/tool-executor.d.ts +24 -3
  69. package/dist/tool-executor.js +277 -139
  70. package/dist/tool-recovery/recovery.js +64 -35
  71. package/dist/tool-recovery/stack.d.ts +2 -1
  72. package/dist/tool-recovery/stack.js +1 -1
  73. package/package.json +10 -10
@@ -160,6 +160,46 @@ function transientSuppressedReason(category, detail, stage) {
160
160
  }
161
161
  return 'non_retryable_category';
162
162
  }
163
+ /** Side effects that make a same-run retry unsafe (it could duplicate work the user already saw),
164
+ * in the order they are reported when several were observed. `cancelRequested` is not listed: it is
165
+ * checked earlier, ahead of the failure signal itself. */
166
+ const SIDE_EFFECT_SUPPRESSIONS = [
167
+ ['userVisibleOutputSeen', 'user_visible_output_seen'],
168
+ ['toolCallSeen', 'tool_call_seen'],
169
+ ['artifactWriteSeen', 'artifact_write_seen'],
170
+ ['liveArtifactSeen', 'live_artifact_seen'],
171
+ ];
172
+ function sideEffectSuppressedReason(sideEffects) {
173
+ const observed = SIDE_EFFECT_SUPPRESSIONS.find(([flag]) => Boolean(sideEffects[flag]));
174
+ return observed === undefined ? null : observed[1];
175
+ }
176
+ // A non-retryable category is reported ahead of the `retryable` flag, while the other transient
177
+ // reasons (an unknown category, an unsafe stage) are reported only after it.
178
+ function failureSuppressedReason(failure) {
179
+ if (!failure)
180
+ return 'missing_failure_signal';
181
+ if (failure.failure_detail === 'hard_quota')
182
+ return 'hard_quota';
183
+ const transientReason = transientSuppressedReason(failure.failure_category, failure.failure_detail, failure.failure_stage);
184
+ if (transientReason === 'non_retryable_category')
185
+ return transientReason;
186
+ if (!failure.retryable)
187
+ return 'not_retryable';
188
+ return transientReason;
189
+ }
190
+ /** The first reason (in policy order) that suppresses a retry, or `null` when a retry is safe. */
191
+ function retrySuppressedReason(input) {
192
+ if (input.result !== 'failed')
193
+ return 'not_failed';
194
+ if (input.sideEffects.cancelRequested)
195
+ return 'cancel_requested';
196
+ const failureReason = failureSuppressedReason(input.failure);
197
+ if (failureReason !== null)
198
+ return failureReason;
199
+ if (input.attemptCount >= input.retryMaxAttempts)
200
+ return 'attempt_limit_reached';
201
+ return sideEffectSuppressedReason(input.sideEffects);
202
+ }
163
203
  /**
164
204
  * Decides whether a failed run should be automatically retried and, if so, schedules the delay.
165
205
  * Suppresses retries when the run was cancelled, emitted user-visible output, or hit a non-transient failure.
@@ -167,52 +207,28 @@ function transientSuppressedReason(category, detail, stage) {
167
207
  * @returns A `RunRetryPolicyDecision` with `shouldRetry: true` and a delay, or `shouldRetry: false` with a suppression reason.
168
208
  */
169
209
  export function decideSafeRunRetry(requiredArgs, optionalArgs = {}) {
170
- const input = { ...requiredArgs, ...optionalArgs };
171
- const attemptCount = normalizeAttemptCount(input.attemptCount);
172
- const retryMaxAttempts = normalizeMaxAttempts(input.maxAttempts);
210
+ const attemptCount = normalizeAttemptCount(requiredArgs.attemptCount);
211
+ const retryMaxAttempts = normalizeMaxAttempts(optionalArgs.maxAttempts);
173
212
  const retryAttemptIndex = attemptCount + 1;
174
213
  const base = {
175
214
  retryAttemptIndex,
176
215
  retryMaxAttempts,
177
216
  retryStrategy: SAFE_RUN_RETRY_STRATEGY,
178
217
  };
179
- const suppress = (retrySuppressedReason) => ({
180
- ...base,
181
- shouldRetry: false,
182
- retrySuppressedReason,
218
+ const reason = retrySuppressedReason({
219
+ result: requiredArgs.result,
220
+ failure: optionalArgs.failure,
221
+ sideEffects: optionalArgs.sideEffects ?? {},
222
+ attemptCount,
223
+ retryMaxAttempts,
183
224
  });
184
- if (input.result !== 'failed')
185
- return suppress('not_failed');
186
- const sideEffects = input.sideEffects ?? {};
187
- if (sideEffects.cancelRequested)
188
- return suppress('cancel_requested');
189
- const failure = input.failure;
190
- if (!failure)
191
- return suppress('missing_failure_signal');
192
- if (failure.failure_detail === 'hard_quota')
193
- return suppress('hard_quota');
194
- const transientReason = transientSuppressedReason(failure.failure_category, failure.failure_detail, failure.failure_stage);
195
- if (transientReason === 'non_retryable_category')
196
- return suppress(transientReason);
197
- if (!failure.retryable)
198
- return suppress('not_retryable');
199
- if (transientReason)
200
- return suppress(transientReason);
201
- if (attemptCount >= retryMaxAttempts)
202
- return suppress('attempt_limit_reached');
203
- if (sideEffects.userVisibleOutputSeen)
204
- return suppress('user_visible_output_seen');
205
- if (sideEffects.toolCallSeen)
206
- return suppress('tool_call_seen');
207
- if (sideEffects.artifactWriteSeen)
208
- return suppress('artifact_write_seen');
209
- if (sideEffects.liveArtifactSeen)
210
- return suppress('live_artifact_seen');
225
+ if (reason !== null)
226
+ return { ...base, shouldRetry: false, retrySuppressedReason: reason };
211
227
  return {
212
228
  ...base,
213
229
  shouldRetry: true,
214
230
  retryReason: 'transient_failure',
215
- retryDelayMs: computeRetryBackoffMs({ attemptIndex: retryAttemptIndex, category: failure.failure_category }, input.random === undefined ? {} : { random: input.random }),
231
+ retryDelayMs: computeRetryBackoffMs({ attemptIndex: retryAttemptIndex, category: optionalArgs.failure?.failure_category }, optionalArgs.random === undefined ? {} : { random: optionalArgs.random }),
216
232
  };
217
233
  }
218
234
  //# sourceMappingURL=retry.js.map
@@ -2,19 +2,7 @@
2
2
  const identityRedactor = ({ text }) => text;
3
3
  const STDERR_TAIL_MAX_LINES = 20;
4
4
  const STDERR_TAIL_MAX_BYTES = 4 * 1024;
5
- function readStderrChunk(data) {
6
- if (typeof data === 'string')
7
- return data;
8
- if (!data || typeof data !== 'object' || Array.isArray(data))
9
- return null;
10
- const obj = data;
11
- if (typeof obj.chunk === 'string')
12
- return obj.chunk;
13
- if (typeof obj.text === 'string')
14
- return obj.text;
15
- return null;
16
- }
17
- function readStdoutChunk(data) {
5
+ function readStreamChunk(data) {
18
6
  if (typeof data === 'string')
19
7
  return data;
20
8
  if (!data || typeof data !== 'object' || Array.isArray(data))
@@ -88,7 +76,7 @@ function collectStreamTailSummary(events, eventName, readChunk, redact) {
88
76
  * @returns A `StderrTailSummary` with the last 20 lines (capped at 4 KB) and a truncation flag, or `undefined`.
89
77
  */
90
78
  export function collectStderrTailSummary(_requiredArgs, { events = [], redact = identityRedactor } = {}) {
91
- return collectStreamTailSummary(events, 'stderr', readStderrChunk, redact);
79
+ return collectStreamTailSummary(events, 'stderr', readStreamChunk, redact);
92
80
  }
93
81
  /**
94
82
  * Collects the tail of the stdout stream from a run's event list, scrubbed by the
@@ -98,7 +86,7 @@ export function collectStderrTailSummary(_requiredArgs, { events = [], redact =
98
86
  * @returns A `StdoutTailSummary` with the last 20 lines (capped at 4 KB) and a truncation flag, or `undefined`.
99
87
  */
100
88
  export function collectStdoutTailSummary(_requiredArgs, { events = [], redact = identityRedactor } = {}) {
101
- return collectStreamTailSummary(events, 'stdout', readStdoutChunk, redact);
89
+ return collectStreamTailSummary(events, 'stdout', readStreamChunk, redact);
102
90
  }
103
91
  /** The set of `rpc_close_reason` strings a `runtime_close` diagnostic may legitimately carry. */
104
92
  const RECOGNIZED_CLOSE_REASONS = new Set([
@@ -136,13 +124,13 @@ function hasVisibleTextDelta(data) {
136
124
  /** Accumulates the raw stderr/stdout text carried by this event, if it is a stream chunk. */
137
125
  function accumulateStreamChunk(observed, event) {
138
126
  if (event.event === 'stderr') {
139
- const chunk = readStderrChunk(event.data);
127
+ const chunk = readStreamChunk(event.data);
140
128
  if (chunk)
141
129
  observed.stderr += chunk;
142
130
  return;
143
131
  }
144
132
  if (event.event === 'stdout') {
145
- const chunk = readStdoutChunk(event.data);
133
+ const chunk = readStreamChunk(event.data);
146
134
  if (chunk) {
147
135
  observed.stdout += chunk;
148
136
  observed.userVisibleOutputSeen = true;
@@ -83,31 +83,22 @@ const bearerPattern = /^Bearer[ \t]+(\S+)[ \t]*$/i;
83
83
  function notFound(runId, stream) {
84
84
  return { allowed: false, status: 404, body: { error: { code: 'NOT_FOUND', message: stream ? 'run was not found' : `run "${runId}" was not found` } } };
85
85
  }
86
- /** Transport-independent bearer gate. Apply principalHeader only after an allowed decision. */
87
- /** Loopback is not authentication: another local process can reach the daemon. No peer-address
88
- * exemption is allowed. Missing configuration refuses service (503); bad credentials receive 401.
89
- * Mount authentication before body parsing and ownership checks. Explicit path exemptions use
90
- * exact equality and apply only without a credential; presenting one always opts into validation.
91
- * A run bearer is bound to its run before route allowlisting and overwrites caller identity.
92
- * Mount the delegated-body gate after parsing so body.runId must also match that credential. */
93
- export function authorizeDaemonRequest({ request, env, envVarName, principalHeaderName, authorizationHeaderName, crypto, routes }, { exemptPaths = [], runScopedCallers, validateDelegatedRunId = false } = {}) {
94
- const authorization = request.headers[authorizationHeaderName];
95
- if (exemptPaths.includes(request.path) && authorization === undefined)
96
- return { allowed: true };
97
- const expected = env[envVarName];
98
- if (typeof expected !== 'string' || expected.length === 0)
99
- return {
100
- allowed: false, status: 503, body: { error: `the agent daemon is not configured: ${envVarName} is unset`, code: 'AGENT_DAEMON_UNCONFIGURED' },
101
- };
86
+ /** A presented bearer is the proxy token or a live run credential; anything else, including a
87
+ * malformed header, is no credential and is never offered to the run resolver. */
88
+ function presentedCredential({ authorization, expected, crypto, runScopedCallers }) {
102
89
  const token = bearerPattern.exec(authorization ?? '')?.[1];
103
- if (token !== undefined && crypto.tokensMatch({ presented: token, expected }))
104
- return { allowed: true };
105
- const caller = token === undefined ? undefined : runScopedCallers?.resolveCaller({ token });
106
- if (caller === undefined)
107
- return { allowed: false, status: 401, body: { error: `Authorization: Bearer <${envVarName}> is required`, code: 'UNAUTHENTICATED' } };
90
+ if (token === undefined)
91
+ return undefined;
92
+ if (crypto.tokensMatch({ presented: token, expected }))
93
+ return { kind: 'proxy' };
94
+ const caller = runScopedCallers?.resolveCaller({ token });
95
+ return caller === undefined ? undefined : { kind: 'run', caller };
96
+ }
97
+ /** Bind a run bearer to its own run before any route allowlisting; undefined means no refusal. */
98
+ function refuseForeignRun({ path, caller, routes }) {
108
99
  let target;
109
100
  try {
110
- target = routes.targetRun({ path: request.path });
101
+ target = routes.targetRun({ path });
111
102
  }
112
103
  catch (error) {
113
104
  if (!(error instanceof URIError))
@@ -115,16 +106,47 @@ export function authorizeDaemonRequest({ request, env, envVarName, principalHead
115
106
  return { allowed: false, status: 400, body: { error: { code: 'BAD_REQUEST', message: 'runId is not valid URL encoding' } } };
116
107
  }
117
108
  if (target !== undefined && target !== caller.runId)
118
- return notFound(target, routes.isEventStream({ path: request.path }));
109
+ return notFound(target, routes.isEventStream({ path }));
110
+ return undefined;
111
+ }
112
+ function bodyRunId(body) {
113
+ return body !== null && typeof body === 'object' ? body['runId'] : undefined;
114
+ }
115
+ function authorizeRunScopedCaller({ request, caller, routes, principalHeaderName, validateDelegatedRunId }) {
116
+ const foreign = refuseForeignRun({ path: request.path, caller, routes });
117
+ if (foreign !== undefined)
118
+ return foreign;
119
119
  if (!routes.allowsRunScoped(request))
120
120
  return { allowed: false, status: 403, body: { error: `a run-scoped credential cannot call ${request.method} ${request.path}`, code: 'FORBIDDEN' } };
121
- if (validateDelegatedRunId && routes.isDelegatedCall(request)) {
122
- const bodyRunId = request.body !== null && typeof request.body === 'object' ? request.body['runId'] : undefined;
123
- if (bodyRunId !== caller.runId)
124
- return { allowed: false, status: 403, body: { error: 'a run-scoped credential requires body.runId to match its own run', code: 'FORBIDDEN' } };
121
+ if (validateDelegatedRunId && routes.isDelegatedCall(request) && bodyRunId(request.body) !== caller.runId) {
122
+ return { allowed: false, status: 403, body: { error: 'a run-scoped credential requires body.runId to match its own run', code: 'FORBIDDEN' } };
125
123
  }
126
124
  return { allowed: true, principalHeader: { name: principalHeaderName, value: caller.principalId } };
127
125
  }
126
+ function unconfigured(envVarName) {
127
+ return { allowed: false, status: 503, body: { error: `the agent daemon is not configured: ${envVarName} is unset`, code: 'AGENT_DAEMON_UNCONFIGURED' } };
128
+ }
129
+ /** Transport-independent bearer gate. Apply principalHeader only after an allowed decision. */
130
+ /** Loopback is not authentication: another local process can reach the daemon. No peer-address
131
+ * exemption is allowed. Missing configuration refuses service (503); bad credentials receive 401.
132
+ * Mount authentication before body parsing and ownership checks. Explicit path exemptions use
133
+ * exact equality and apply only without a credential; presenting one always opts into validation.
134
+ * A run bearer is bound to its run before route allowlisting and overwrites caller identity.
135
+ * Mount the delegated-body gate after parsing so body.runId must also match that credential. */
136
+ export function authorizeDaemonRequest({ request, env, envVarName, principalHeaderName, authorizationHeaderName, crypto, routes }, { exemptPaths = [], runScopedCallers, validateDelegatedRunId = false } = {}) {
137
+ const authorization = request.headers[authorizationHeaderName];
138
+ if (exemptPaths.includes(request.path) && authorization === undefined)
139
+ return { allowed: true };
140
+ const expected = env[envVarName];
141
+ if (typeof expected !== 'string' || expected.length === 0)
142
+ return unconfigured(envVarName);
143
+ const credential = presentedCredential({ authorization, expected, crypto, runScopedCallers });
144
+ if (credential === undefined)
145
+ return { allowed: false, status: 401, body: { error: `Authorization: Bearer <${envVarName}> is required`, code: 'UNAUTHENTICATED' } };
146
+ if (credential.kind === 'proxy')
147
+ return { allowed: true };
148
+ return authorizeRunScopedCaller({ request, caller: credential.caller, routes, principalHeaderName, validateDelegatedRunId });
149
+ }
128
150
  export function createRunOwnerRegistry(_args, _optional = {}) {
129
151
  const owners = new Map();
130
152
  return {
@@ -283,6 +283,15 @@ export interface CreateRunLifecycleInput {
283
283
  readonly terminalRetentionMs?: number;
284
284
  /** Hard cap on concurrently-retained terminal run records. See {@link DEFAULT_MAX_TERMINAL_RUNS}. Defaults to that constant. */
285
285
  readonly maxTerminalRuns?: number;
286
+ /**
287
+ * When `true`, evicting a terminal run record (retention timer or `maxTerminalRuns` cap) also calls
288
+ * `eventLog.drop()` for it, giving the run's events the same lifetime as its record. Off by default
289
+ * because a durable adapter's `drop()` deletes stored rows (`@jini-ai/daemon/store/event-log/sqlite`),
290
+ * which would turn a memory bound into data loss for a host that never asked for it; a host whose
291
+ * log is in-memory only can opt in. A failed drop is reported through `onInternalError`
292
+ * (`source: 'event-log-drop'`).
293
+ */
294
+ readonly dropEventsOnEviction?: boolean;
286
295
  /**
287
296
  * How long a run may go with no agent-event `emit()` (excluding the notice itself) before a `'slow_running'` agent event tells the UI the
288
297
  * turn is taking longer than usual — never a terminating action; see
@@ -295,7 +304,7 @@ export interface CreateRunLifecycleInput {
295
304
  readonly slowRunThresholdMs?: number | null;
296
305
  }
297
306
  export interface RunLifecycleInternalErrorContext {
298
- readonly source: 'inactivity-timeout' | 'slow-run-notice';
307
+ readonly source: 'inactivity-timeout' | 'slow-run-notice' | 'event-log-drop';
299
308
  readonly runId: string;
300
309
  readonly error: unknown;
301
310
  }
@@ -305,9 +314,10 @@ export interface RunLifecycleInternalErrorContext {
305
314
  * @param input.eventLog - The durable `EventLog` port this lifecycle appends to and replays from.
306
315
  * @param input.terminalRetentionMs - See {@link CreateRunLifecycleInput.terminalRetentionMs}.
307
316
  * @param input.maxTerminalRuns - See {@link CreateRunLifecycleInput.maxTerminalRuns}.
317
+ * @param input.dropEventsOnEviction - See {@link CreateRunLifecycleInput.dropEventsOnEviction}.
308
318
  * @returns A `RunLifecycle` backed by an in-memory run registry plus the injected `EventLog`.
309
319
  * @complexity Per-call complexities documented on each method; the registry itself is a `Map` keyed by `runId` (O(1) lookup). Memory is bounded: non-terminal runs are O(n) in concurrently-live runs, and terminal runs are capped at `maxTerminalRuns` (each additionally bounded in retention time by `terminalRetentionMs`) rather than growing for the life of the process.
310
320
  * @overallScore 100/100
311
321
  */
312
- export declare function createRunLifecycle(requiredArgs: Pick<CreateRunLifecycleInput, "eventLog">, optionalArgs?: Pick<CreateRunLifecycleInput, "onInternalError" | "terminalRetentionMs" | "maxTerminalRuns" | "slowRunThresholdMs">): RunLifecycle;
322
+ export declare function createRunLifecycle(requiredArgs: Pick<CreateRunLifecycleInput, "eventLog">, optionalArgs?: Pick<CreateRunLifecycleInput, "onInternalError" | "terminalRetentionMs" | "maxTerminalRuns" | "slowRunThresholdMs" | "dropEventsOnEviction">): RunLifecycle;
313
323
  //# sourceMappingURL=run-lifecycle.d.ts.map
@@ -108,7 +108,9 @@ function rehydratedRunRecord(runId, entries) {
108
108
  retentionTimer: undefined,
109
109
  },
110
110
  idempotencyKey,
111
- isTerminal: terminal !== null,
111
+ // Terminal exactly when the log holds an 'end' entry (`terminal` is derived from it), so the
112
+ // end entry's own timestamp is the terminal time — never a "now" substitute.
113
+ terminalAt: endEntry?.recordedAt,
112
114
  };
113
115
  }
114
116
  function terminalStateFromEndEntry(entry) {
@@ -303,6 +305,7 @@ function buildNewRunRecord(startInput, runId, now) {
303
305
  * @param input.eventLog - The durable `EventLog` port this lifecycle appends to and replays from.
304
306
  * @param input.terminalRetentionMs - See {@link CreateRunLifecycleInput.terminalRetentionMs}.
305
307
  * @param input.maxTerminalRuns - See {@link CreateRunLifecycleInput.maxTerminalRuns}.
308
+ * @param input.dropEventsOnEviction - See {@link CreateRunLifecycleInput.dropEventsOnEviction}.
306
309
  * @returns A `RunLifecycle` backed by an in-memory run registry plus the injected `EventLog`.
307
310
  * @complexity Per-call complexities documented on each method; the registry itself is a `Map` keyed by `runId` (O(1) lookup). Memory is bounded: non-terminal runs are O(n) in concurrently-live runs, and terminal runs are capped at `maxTerminalRuns` (each additionally bounded in retention time by `terminalRetentionMs`) rather than growing for the life of the process.
308
311
  * @overallScore 100/100
@@ -416,6 +419,31 @@ export function createRunLifecycle(requiredArgs, optionalArgs = {}) {
416
419
  record.retentionTimer = undefined;
417
420
  runs.delete(runId);
418
421
  clearIdempotencyIndexEntryIfMatching({ idempotencyIndex: idempotencyIndex, idempotencyKey: record.idempotencyKey, runId: runId });
422
+ if (input.dropEventsOnEviction === true)
423
+ void dropEvictedRunEvents(runId);
424
+ }
425
+ /** `evictTerminalRun`'s opt-in event drop (see `CreateRunLifecycleInput.dropEventsOnEviction`). Runs from a timer, so a failure is reported, never thrown. */
426
+ async function dropEvictedRunEvents(runId) {
427
+ try {
428
+ await eventLog.drop({ runId });
429
+ }
430
+ catch (error) {
431
+ reportInternalError({ source: 'event-log-drop', runId, error });
432
+ }
433
+ }
434
+ /** Hands `context` to `onInternalError` (or `console.error`), containing a throwing sink. */
435
+ function reportInternalError(context) {
436
+ try {
437
+ if (input.onInternalError)
438
+ input.onInternalError(context);
439
+ // eslint-disable-next-line no-console
440
+ else
441
+ console.error(`[@jini-ai/daemon] internal error (${context.source}, runId=${context.runId})`, context.error);
442
+ }
443
+ catch (sinkError) {
444
+ // eslint-disable-next-line no-console
445
+ console.error(`[@jini-ai/daemon] internal error sink failed (${context.source}, runId=${context.runId})`, sinkError);
446
+ }
419
447
  }
420
448
  /** Trims the oldest terminal records until `terminalRunOrder` is back at or under `maxTerminalRuns`. */
421
449
  function enforceTerminalCap() {
@@ -454,29 +482,25 @@ export function createRunLifecycle(requiredArgs, optionalArgs = {}) {
454
482
  * it would undo the operator's inactivity policy. Other unexpected process failures still
455
483
  * retain their original runtime classification.
456
484
  *
457
- * The `!record || isTerminalRunState(...)` guard below is defensive only and is currently
458
- * unreachable through the public API — deliberately kept, not fake-tested, and the reason is
459
- * recorded here so the next reader does not spend time trying to cover it:
485
+ * There is deliberately no "run missing or already terminal" guard here (an earlier
486
+ * `!record || isTerminalRunState(...)` guard was removed as unreachable through the public API,
487
+ * and the handler needs nothing from the record — it acts through `lifecycle.emit`/`finish` by id):
460
488
  *
461
- * - `isTerminalRunState(...)`: every route to a terminal state runs through `finish()`, which
489
+ * - terminal: every route to a terminal state runs through `finish()`, which
462
490
  * calls `record.watchdog?.cancel()` and assigns `record.status.state` in the *same synchronous
463
491
  * block* after its durable end append resolves. There is no interleaving point between the two,
464
492
  * so "watchdog still armed" and "record already terminal" cannot both hold when this fires. (If
465
493
  * the end append *throws*, the watchdog stays armed but the run also stays non-terminal.)
466
- * - `!record`: the only `runs.delete()` is `start()`'s unwind on a failed durable start append,
467
- * which happens strictly before the watchdog is armed a few lines later. It is also required
468
- * for type-narrowing `record` before `record.status.state` is read, so it cannot simply be
469
- * dropped.
494
+ * - missing: the only `runs.delete()` calls are `start()`'s unwind on a failed durable start
495
+ * append, which happens strictly before the watchdog is armed a few lines later, and terminal
496
+ * eviction, which only ever touches runs whose watchdog `finish()` already cancelled. And
497
+ * `createInactivityWatchdog` never calls `onTimeout` after `cancel()`.
470
498
  *
471
- * Note the "start/finish race" test below does *not* exercise this guard despite its name: by the
499
+ * Note the "start/finish race" test below does *not* exercise this handler despite its name: by the
472
500
  * time it advances the timers, `finish()` has already cancelled the watchdog, so the callback
473
501
  * never runs at all. It still correctly asserts that no second `finish()` happens.
474
502
  */
475
503
  async function handleInactivityTimeout(runId) {
476
- const record = runs.get(runId);
477
- if (!record || isTerminalRunState({ state: record.status.state })) {
478
- return;
479
- }
480
504
  try {
481
505
  // A deliberate inactivity kill is permanent for this attempt, not an auto-resume signal.
482
506
  await lifecycle.emit({ runId, input: { event: 'agent', data: { type: 'status', label: 'Stopped by inactivity watchdog', detail: 'inactivity-watchdog' } } });
@@ -510,21 +534,17 @@ export function createRunLifecycle(requiredArgs, optionalArgs = {}) {
510
534
  * one — see that variant's own doc in `@jini-ai/protocol`'s `events.ts` for why: nothing in this
511
535
  * codebase renders `'status'` events today, so reusing it here would have shipped an invisible fix.
512
536
  *
513
- * The `!record || isTerminalRunState(...)` guard mirrors {@link handleInactivityTimeout}'s own
514
- * (see that function's doc for why it is unreachable-but-kept there); it is reachable here in one
515
- * more case worth naming: `finish()` cancels `record.slowRunWatchdog` synchronously before its
537
+ * Like {@link handleInactivityTimeout}, it has no "run missing or already terminal" guard (see
538
+ * that function's doc for why such a guard is unreachable). One more case is worth naming:
539
+ * `finish()` cancels `record.slowRunWatchdog` synchronously before its
516
540
  * `record.status.state` write ever yields, so between this timer firing and `emit()` actually
517
541
  * running there is no window where a normal `finish()` could race it — but `emit()` itself can
518
542
  * still legitimately throw (e.g. a `finishPromise` in flight from a concurrent `cancel()`-driven
519
- * finish that started after this callback's own `runs.get()` read but before its `emit()` call
543
+ * finish that started after this callback fired but before its `emit()` call
520
544
  * completes its internal `await record.startPromise`), which is exactly what the `try`/`catch`
521
545
  * below contains rather than letting escape as an unhandled rejection.
522
546
  */
523
547
  async function handleSlowRunNotice(runId) {
524
- const record = runs.get(runId);
525
- if (!record || isTerminalRunState({ state: record.status.state })) {
526
- return;
527
- }
528
548
  try {
529
549
  await lifecycle.emit({ runId: runId, input: {
530
550
  event: 'agent',
@@ -563,7 +583,7 @@ export function createRunLifecycle(requiredArgs, optionalArgs = {}) {
563
583
  const replay = await eventLog.replay({ runId: runId, afterCursor: null });
564
584
  if (replay.kind !== 'ok' || replay.entries.length === 0)
565
585
  return 'skipped';
566
- const { record, idempotencyKey, isTerminal } = rehydratedRunRecord(runId, replay.entries);
586
+ const { record, idempotencyKey, terminalAt } = rehydratedRunRecord(runId, replay.entries);
567
587
  runs.set(runId, record);
568
588
  if (idempotencyKey !== undefined)
569
589
  idempotencyIndex.set(idempotencyKey, runId);
@@ -571,9 +591,10 @@ export function createRunLifecycle(requiredArgs, optionalArgs = {}) {
571
591
  // its own — without this, a restart would re-populate `runs` with the daemon's entire terminal
572
592
  // run history on every boot, reproducing the same unbounded growth this module now guards
573
593
  // against at runtime.
574
- if (isTerminal)
575
- trackTerminalRun(runId, record, record.status.endedAt ?? Date.now());
576
- return isTerminal ? 'terminal' : 'non-terminal';
594
+ if (terminalAt === undefined)
595
+ return 'non-terminal';
596
+ trackTerminalRun(runId, record, terminalAt);
597
+ return 'terminal';
577
598
  }
578
599
  const lifecycle = {
579
600
  async rehydrate(_args) {
@@ -27,6 +27,7 @@ export declare function getAgentSession({ db, conversationId, agentId }: {
27
27
  * ability to detect staleness via stablePromptHash and lastMessageId.
28
28
  * @param db - Open SQLite database handle.
29
29
  * @param input - Session fields to persist; conversationId, agentId, and sessionId are required.
30
+ * @param nowMs - Clock for `updated_at`; defaults to wall time.
30
31
  */
31
32
  export declare function upsertAgentSession({ db, input }: {
32
33
  readonly db: SqliteDb;
@@ -39,6 +40,8 @@ export declare function upsertAgentSession({ db, input }: {
39
40
  cwd?: string | null;
40
41
  lastMessageId?: string | null;
41
42
  };
43
+ }, { nowMs }?: {
44
+ readonly nowMs?: () => number;
42
45
  }): void;
43
46
  /**
44
47
  * Fetches the full agent session record for a (conversation, agent) pair, or null if absent.
@@ -83,12 +86,15 @@ export declare function latestCompletedAssistantMessageId({ db, conversationId,
83
86
  * @param conversationId - Conversation scope for the session.
84
87
  * @param agentId - Agent whose session to update.
85
88
  * @param stablePromptHash - The new prompt hash to record.
89
+ * @param nowMs - Clock for `updated_at`; defaults to wall time.
86
90
  */
87
91
  export declare function updateAgentSessionStableHash({ db, conversationId, agentId, stablePromptHash }: {
88
92
  readonly db: SqliteDb;
89
93
  readonly conversationId: string;
90
94
  readonly agentId: string;
91
95
  readonly stablePromptHash: string;
96
+ }, { nowMs }?: {
97
+ readonly nowMs?: () => number;
92
98
  }): void;
93
99
  /**
94
100
  * Deletes the agent session record for a (conversation, agent) pair.
@@ -18,8 +18,9 @@ export function getAgentSession({ db, conversationId, agentId }) {
18
18
  * ability to detect staleness via stablePromptHash and lastMessageId.
19
19
  * @param db - Open SQLite database handle.
20
20
  * @param input - Session fields to persist; conversationId, agentId, and sessionId are required.
21
+ * @param nowMs - Clock for `updated_at`; defaults to wall time.
21
22
  */
22
- export function upsertAgentSession({ db, input }) {
23
+ export function upsertAgentSession({ db, input }, { nowMs = Date.now } = {}) {
23
24
  db.prepare(`INSERT INTO agent_sessions
24
25
  (conversation_id, agent_id, session_id, stable_prompt_hash, model, cwd, last_message_id, updated_at)
25
26
  VALUES (?, ?, ?, ?, ?, ?, ?, ?)
@@ -29,7 +30,7 @@ export function upsertAgentSession({ db, input }) {
29
30
  model = excluded.model,
30
31
  cwd = excluded.cwd,
31
32
  last_message_id = excluded.last_message_id,
32
- updated_at = excluded.updated_at`).run(input.conversationId, input.agentId, input.sessionId, input.stablePromptHash ?? null, input.model ?? null, input.cwd ?? null, input.lastMessageId ?? null, Date.now());
33
+ updated_at = excluded.updated_at`).run(input.conversationId, input.agentId, input.sessionId, input.stablePromptHash ?? null, input.model ?? null, input.cwd ?? null, input.lastMessageId ?? null, nowMs());
33
34
  }
34
35
  /**
35
36
  * Fetches the full agent session record for a (conversation, agent) pair, or null if absent.
@@ -103,10 +104,11 @@ export function latestCompletedAssistantMessageId({ db, conversationId, excludeM
103
104
  * @param conversationId - Conversation scope for the session.
104
105
  * @param agentId - Agent whose session to update.
105
106
  * @param stablePromptHash - The new prompt hash to record.
107
+ * @param nowMs - Clock for `updated_at`; defaults to wall time.
106
108
  */
107
- export function updateAgentSessionStableHash({ db, conversationId, agentId, stablePromptHash }) {
109
+ export function updateAgentSessionStableHash({ db, conversationId, agentId, stablePromptHash }, { nowMs = Date.now } = {}) {
108
110
  db.prepare(`UPDATE agent_sessions SET stable_prompt_hash = ?, updated_at = ?
109
- WHERE conversation_id = ? AND agent_id = ?`).run(stablePromptHash, Date.now(), conversationId, agentId);
111
+ WHERE conversation_id = ? AND agent_id = ?`).run(stablePromptHash, nowMs(), conversationId, agentId);
110
112
  }
111
113
  /**
112
114
  * Deletes the agent session record for a (conversation, agent) pair.
@@ -91,8 +91,9 @@ export function createSurfaceExchangeStore(deps, { idleTtlMs = DEFAULT_SURFACE_I
91
91
  armIdle();
92
92
  }
93
93
  catch (error) {
94
+ // Only the lifetime timer can be live here: `armIdle` is the last statement, and it assigns
95
+ // `cancelIdle` only once its own `schedule` call has returned, so a throw there leaves it unset.
94
96
  cancelLifetime?.();
95
- cancelIdle?.();
96
97
  throw error;
97
98
  }
98
99
  openExchanges.set(id, {
@@ -147,7 +147,7 @@ export interface CreateTerminalSessionManagerOptions {
147
147
  * the kill/write/resize lock, and (via {@link createTerminalToolRegistrations})
148
148
  * `ToolExecutor` gating for creation. See module doc for the full design.
149
149
  */
150
- export declare function createTerminalSessionManager(requiredArgs: Record<string, never>, optionalArgs?: Pick<CreateTerminalSessionManagerOptions, "terminalService" | "loadSpawnPty" | "maxEvents" | "maxBufferBytes" | "exitTailBytes" | "flushIntervalMs" | "flushThresholdBytes" | "ttlMs" | "shutdownGraceMs">): TerminalSessionManager;
150
+ export declare function createTerminalSessionManager(_requiredArgs: Record<string, never>, options?: Pick<CreateTerminalSessionManagerOptions, "terminalService" | "loadSpawnPty" | "maxEvents" | "maxBufferBytes" | "exitTailBytes" | "flushIntervalMs" | "flushThresholdBytes" | "ttlMs" | "shutdownGraceMs">): TerminalSessionManager;
151
151
  export interface CreateTerminalToolRegistrationsOptions {
152
152
  readonly manager: TerminalSessionManager;
153
153
  /** @default {@link denyAllTerminalCreatePolicy} — see its doc for why a permissive default was rejected. */