@jini-ai/daemon 0.6.8 → 0.6.10

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 (79) hide show
  1. package/CHANGELOG.md +148 -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 +37 -6
  5. package/dist/agent-executor/event-translation.d.ts +6 -1
  6. package/dist/agent-executor/event-translation.js +3 -1
  7. package/dist/agent-executor/events.d.ts +5 -0
  8. package/dist/agent-executor/events.js +143 -87
  9. package/dist/agent-executor/launch.d.ts +5 -3
  10. package/dist/agent-executor/launch.js +315 -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 +15 -0
  18. package/dist/core/read-only-tools.js +29 -1
  19. package/dist/core/tool-audit.d.ts +3 -1
  20. package/dist/core/tool-audit.js +35 -22
  21. package/dist/core/tool-gaps.d.ts +60 -0
  22. package/dist/core/tool-gaps.js +199 -0
  23. package/dist/delegated-result-correlation.d.ts +26 -0
  24. package/dist/delegated-result-correlation.js +68 -0
  25. package/dist/delegated-tool-bridge.d.ts +6 -14
  26. package/dist/delegated-tool-bridge.js +155 -155
  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 +8 -1
  34. package/dist/http/delegated-tools.js +30 -30
  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/index.d.ts +2 -0
  56. package/dist/index.js +1 -0
  57. package/dist/legacy-data-migration.js +41 -24
  58. package/dist/run/core/retry.js +51 -35
  59. package/dist/run/diagnostics/diagnostics.js +5 -17
  60. package/dist/run-credentials.js +49 -27
  61. package/dist/run-lifecycle.d.ts +31 -2
  62. package/dist/run-lifecycle.js +49 -42
  63. package/dist/store/agent-sessions/sqlite.d.ts +6 -0
  64. package/dist/store/agent-sessions/sqlite.js +6 -4
  65. package/dist/surface-exchanges.js +2 -1
  66. package/dist/terminal-session.d.ts +1 -1
  67. package/dist/terminal-session.js +26 -33
  68. package/dist/tool-audit.d.ts +1 -0
  69. package/dist/tool-audit.js +1 -0
  70. package/dist/tool-executor.d.ts +24 -3
  71. package/dist/tool-executor.js +276 -139
  72. package/dist/tool-recovery/recovery.js +64 -35
  73. package/dist/tool-recovery/stack.d.ts +2 -1
  74. package/dist/tool-recovery/stack.js +1 -1
  75. package/dist/tool-result-diagnostics.d.ts +27 -0
  76. package/dist/tool-result-diagnostics.js +77 -0
  77. package/dist/tool-result-media.d.ts +27 -14
  78. package/dist/tool-result-media.js +51 -38
  79. package/package.json +14 -14
@@ -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 {
@@ -1,3 +1,20 @@
1
+ /**
2
+ * `RunLifecycle` — start/stream/cancel/resume a run keyed on an opaque
3
+ * `contextRef` (extraction-plan §2.1: runs key on an opaque `contextRef`,
4
+ * never a product-record identifier), generalized from OD's `design.runs` service
5
+ * (`apps/daemon/src/runtimes/runs.ts` + the terminal-decision half of
6
+ * `apps/daemon/src/runtimes/start-chat-run.ts`, `arch/server-startserver-endgame`
7
+ * branch — see `archived provenance ledger` for exact line citations).
8
+ *
9
+ * Scope boundary: this module owns the run *state machine* and its event
10
+ * log — create, transition, emit, replay, cancellation-intent propagation,
11
+ * idempotent start/finish, resume. It does not spawn or signal a subprocess;
12
+ * that is `@jini-ai/agent-runtime`'s job (extraction-plan task 7). A driver
13
+ * (today: this package's tests; later: agent-runtime) calls `emit()` for
14
+ * agent/stdout/stderr/error events, observes cancellation via
15
+ * `onCancelRequested`, and calls `finish()` once it knows the real outcome.
16
+ */
17
+ import type { DelegatedResultCorrelation } from './delegated-result-correlation.js';
1
18
  import type { RunAgentPayload, RunCancelRequest, RunChunkPayload, RunErrorPayload, RunProtocolEvent, RunStartPayload, RunStatus } from '@jini-ai/protocol';
2
19
  import type { EventLog, EventLogEntry, EventLogReplayResult } from './event-log.js';
3
20
  import type { InactivityWatchdog, TerminalRunOutcome } from './close-status.js';
@@ -276,6 +293,8 @@ export declare function finishStreamSubscription({ record, subscriber, terminal
276
293
  readonly terminal: boolean;
277
294
  }): StreamSubscribeResult;
278
295
  export interface CreateRunLifecycleInput {
296
+ /** Share the negotiated bridge/executor digest store so run eviction also releases proofs. */
297
+ readonly delegatedResultCorrelation?: DelegatedResultCorrelation;
279
298
  readonly eventLog: EventLog;
280
299
  /** Host-owned sink for asynchronous lifecycle failures that cannot be returned to a caller. Defaults to `console.error`. */
281
300
  readonly onInternalError?: (context: RunLifecycleInternalErrorContext) => void;
@@ -283,6 +302,15 @@ export interface CreateRunLifecycleInput {
283
302
  readonly terminalRetentionMs?: number;
284
303
  /** Hard cap on concurrently-retained terminal run records. See {@link DEFAULT_MAX_TERMINAL_RUNS}. Defaults to that constant. */
285
304
  readonly maxTerminalRuns?: number;
305
+ /**
306
+ * When `true`, evicting a terminal run record (retention timer or `maxTerminalRuns` cap) also calls
307
+ * `eventLog.drop()` for it, giving the run's events the same lifetime as its record. Off by default
308
+ * because a durable adapter's `drop()` deletes stored rows (`@jini-ai/daemon/store/event-log/sqlite`),
309
+ * which would turn a memory bound into data loss for a host that never asked for it; a host whose
310
+ * log is in-memory only can opt in. A failed drop is reported through `onInternalError`
311
+ * (`source: 'event-log-drop'`).
312
+ */
313
+ readonly dropEventsOnEviction?: boolean;
286
314
  /**
287
315
  * 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
316
  * turn is taking longer than usual — never a terminating action; see
@@ -295,7 +323,7 @@ export interface CreateRunLifecycleInput {
295
323
  readonly slowRunThresholdMs?: number | null;
296
324
  }
297
325
  export interface RunLifecycleInternalErrorContext {
298
- readonly source: 'inactivity-timeout' | 'slow-run-notice';
326
+ readonly source: 'inactivity-timeout' | 'slow-run-notice' | 'event-log-drop';
299
327
  readonly runId: string;
300
328
  readonly error: unknown;
301
329
  }
@@ -305,9 +333,10 @@ export interface RunLifecycleInternalErrorContext {
305
333
  * @param input.eventLog - The durable `EventLog` port this lifecycle appends to and replays from.
306
334
  * @param input.terminalRetentionMs - See {@link CreateRunLifecycleInput.terminalRetentionMs}.
307
335
  * @param input.maxTerminalRuns - See {@link CreateRunLifecycleInput.maxTerminalRuns}.
336
+ * @param input.dropEventsOnEviction - See {@link CreateRunLifecycleInput.dropEventsOnEviction}.
308
337
  * @returns A `RunLifecycle` backed by an in-memory run registry plus the injected `EventLog`.
309
338
  * @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
339
  * @overallScore 100/100
311
340
  */
312
- export declare function createRunLifecycle(requiredArgs: Pick<CreateRunLifecycleInput, "eventLog">, optionalArgs?: Pick<CreateRunLifecycleInput, "onInternalError" | "terminalRetentionMs" | "maxTerminalRuns" | "slowRunThresholdMs">): RunLifecycle;
341
+ export declare function createRunLifecycle(requiredArgs: Pick<CreateRunLifecycleInput, "eventLog">, optionalArgs?: Pick<CreateRunLifecycleInput, "onInternalError" | "terminalRetentionMs" | "maxTerminalRuns" | "slowRunThresholdMs" | "dropEventsOnEviction" | "delegatedResultCorrelation">): RunLifecycle;
313
342
  //# sourceMappingURL=run-lifecycle.d.ts.map
@@ -1,19 +1,3 @@
1
- /**
2
- * `RunLifecycle` — start/stream/cancel/resume a run keyed on an opaque
3
- * `contextRef` (extraction-plan §2.1: runs key on an opaque `contextRef`,
4
- * never a product-record identifier), generalized from OD's `design.runs` service
5
- * (`apps/daemon/src/runtimes/runs.ts` + the terminal-decision half of
6
- * `apps/daemon/src/runtimes/start-chat-run.ts`, `arch/server-startserver-endgame`
7
- * branch — see `archived provenance ledger` for exact line citations).
8
- *
9
- * Scope boundary: this module owns the run *state machine* and its event
10
- * log — create, transition, emit, replay, cancellation-intent propagation,
11
- * idempotent start/finish, resume. It does not spawn or signal a subprocess;
12
- * that is `@jini-ai/agent-runtime`'s job (extraction-plan task 7). A driver
13
- * (today: this package's tests; later: agent-runtime) calls `emit()` for
14
- * agent/stdout/stderr/error events, observes cancellation via
15
- * `onCancelRequested`, and calls `finish()` once it knows the real outcome.
16
- */
17
1
  import { randomUUID } from 'node:crypto';
18
2
  import { RUN_PROTOCOL_VERSION, isTerminalRunState } from '@jini-ai/protocol';
19
3
  import { createInactivityWatchdog } from './close-status.js';
@@ -108,7 +92,9 @@ function rehydratedRunRecord(runId, entries) {
108
92
  retentionTimer: undefined,
109
93
  },
110
94
  idempotencyKey,
111
- isTerminal: terminal !== null,
95
+ // Terminal exactly when the log holds an 'end' entry (`terminal` is derived from it), so the
96
+ // end entry's own timestamp is the terminal time — never a "now" substitute.
97
+ terminalAt: endEntry?.recordedAt,
112
98
  };
113
99
  }
114
100
  function terminalStateFromEndEntry(entry) {
@@ -303,6 +289,7 @@ function buildNewRunRecord(startInput, runId, now) {
303
289
  * @param input.eventLog - The durable `EventLog` port this lifecycle appends to and replays from.
304
290
  * @param input.terminalRetentionMs - See {@link CreateRunLifecycleInput.terminalRetentionMs}.
305
291
  * @param input.maxTerminalRuns - See {@link CreateRunLifecycleInput.maxTerminalRuns}.
292
+ * @param input.dropEventsOnEviction - See {@link CreateRunLifecycleInput.dropEventsOnEviction}.
306
293
  * @returns A `RunLifecycle` backed by an in-memory run registry plus the injected `EventLog`.
307
294
  * @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
295
  * @overallScore 100/100
@@ -364,6 +351,7 @@ export function createRunLifecycle(requiredArgs, optionalArgs = {}) {
364
351
  }
365
352
  catch (error) {
366
353
  runs.delete(runId);
354
+ input.delegatedResultCorrelation?.clearRun({ runId });
367
355
  clearIdempotencyIndexEntryIfMatching({ idempotencyIndex: idempotencyIndex, idempotencyKey: startInput.idempotencyKey, runId: runId });
368
356
  throw error;
369
357
  }
@@ -415,7 +403,33 @@ export function createRunLifecycle(requiredArgs, optionalArgs = {}) {
415
403
  return;
416
404
  record.retentionTimer = undefined;
417
405
  runs.delete(runId);
406
+ input.delegatedResultCorrelation?.clearRun({ runId });
418
407
  clearIdempotencyIndexEntryIfMatching({ idempotencyIndex: idempotencyIndex, idempotencyKey: record.idempotencyKey, runId: runId });
408
+ if (input.dropEventsOnEviction === true)
409
+ void dropEvictedRunEvents(runId);
410
+ }
411
+ /** `evictTerminalRun`'s opt-in event drop (see `CreateRunLifecycleInput.dropEventsOnEviction`). Runs from a timer, so a failure is reported, never thrown. */
412
+ async function dropEvictedRunEvents(runId) {
413
+ try {
414
+ await eventLog.drop({ runId });
415
+ }
416
+ catch (error) {
417
+ reportInternalError({ source: 'event-log-drop', runId, error });
418
+ }
419
+ }
420
+ /** Hands `context` to `onInternalError` (or `console.error`), containing a throwing sink. */
421
+ function reportInternalError(context) {
422
+ try {
423
+ if (input.onInternalError)
424
+ input.onInternalError(context);
425
+ // eslint-disable-next-line no-console
426
+ else
427
+ console.error(`[@jini-ai/daemon] internal error (${context.source}, runId=${context.runId})`, context.error);
428
+ }
429
+ catch (sinkError) {
430
+ // eslint-disable-next-line no-console
431
+ console.error(`[@jini-ai/daemon] internal error sink failed (${context.source}, runId=${context.runId})`, sinkError);
432
+ }
419
433
  }
420
434
  /** Trims the oldest terminal records until `terminalRunOrder` is back at or under `maxTerminalRuns`. */
421
435
  function enforceTerminalCap() {
@@ -454,29 +468,25 @@ export function createRunLifecycle(requiredArgs, optionalArgs = {}) {
454
468
  * it would undo the operator's inactivity policy. Other unexpected process failures still
455
469
  * retain their original runtime classification.
456
470
  *
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:
471
+ * There is deliberately no "run missing or already terminal" guard here (an earlier
472
+ * `!record || isTerminalRunState(...)` guard was removed as unreachable through the public API,
473
+ * and the handler needs nothing from the record — it acts through `lifecycle.emit`/`finish` by id):
460
474
  *
461
- * - `isTerminalRunState(...)`: every route to a terminal state runs through `finish()`, which
475
+ * - terminal: every route to a terminal state runs through `finish()`, which
462
476
  * calls `record.watchdog?.cancel()` and assigns `record.status.state` in the *same synchronous
463
477
  * block* after its durable end append resolves. There is no interleaving point between the two,
464
478
  * so "watchdog still armed" and "record already terminal" cannot both hold when this fires. (If
465
479
  * 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.
480
+ * - missing: the only `runs.delete()` calls are `start()`'s unwind on a failed durable start
481
+ * append, which happens strictly before the watchdog is armed a few lines later, and terminal
482
+ * eviction, which only ever touches runs whose watchdog `finish()` already cancelled. And
483
+ * `createInactivityWatchdog` never calls `onTimeout` after `cancel()`.
470
484
  *
471
- * Note the "start/finish race" test below does *not* exercise this guard despite its name: by the
485
+ * Note the "start/finish race" test below does *not* exercise this handler despite its name: by the
472
486
  * time it advances the timers, `finish()` has already cancelled the watchdog, so the callback
473
487
  * never runs at all. It still correctly asserts that no second `finish()` happens.
474
488
  */
475
489
  async function handleInactivityTimeout(runId) {
476
- const record = runs.get(runId);
477
- if (!record || isTerminalRunState({ state: record.status.state })) {
478
- return;
479
- }
480
490
  try {
481
491
  // A deliberate inactivity kill is permanent for this attempt, not an auto-resume signal.
482
492
  await lifecycle.emit({ runId, input: { event: 'agent', data: { type: 'status', label: 'Stopped by inactivity watchdog', detail: 'inactivity-watchdog' } } });
@@ -510,21 +520,17 @@ export function createRunLifecycle(requiredArgs, optionalArgs = {}) {
510
520
  * one — see that variant's own doc in `@jini-ai/protocol`'s `events.ts` for why: nothing in this
511
521
  * codebase renders `'status'` events today, so reusing it here would have shipped an invisible fix.
512
522
  *
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
523
+ * Like {@link handleInactivityTimeout}, it has no "run missing or already terminal" guard (see
524
+ * that function's doc for why such a guard is unreachable). One more case is worth naming:
525
+ * `finish()` cancels `record.slowRunWatchdog` synchronously before its
516
526
  * `record.status.state` write ever yields, so between this timer firing and `emit()` actually
517
527
  * running there is no window where a normal `finish()` could race it — but `emit()` itself can
518
528
  * 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
529
+ * finish that started after this callback fired but before its `emit()` call
520
530
  * completes its internal `await record.startPromise`), which is exactly what the `try`/`catch`
521
531
  * below contains rather than letting escape as an unhandled rejection.
522
532
  */
523
533
  async function handleSlowRunNotice(runId) {
524
- const record = runs.get(runId);
525
- if (!record || isTerminalRunState({ state: record.status.state })) {
526
- return;
527
- }
528
534
  try {
529
535
  await lifecycle.emit({ runId: runId, input: {
530
536
  event: 'agent',
@@ -563,7 +569,7 @@ export function createRunLifecycle(requiredArgs, optionalArgs = {}) {
563
569
  const replay = await eventLog.replay({ runId: runId, afterCursor: null });
564
570
  if (replay.kind !== 'ok' || replay.entries.length === 0)
565
571
  return 'skipped';
566
- const { record, idempotencyKey, isTerminal } = rehydratedRunRecord(runId, replay.entries);
572
+ const { record, idempotencyKey, terminalAt } = rehydratedRunRecord(runId, replay.entries);
567
573
  runs.set(runId, record);
568
574
  if (idempotencyKey !== undefined)
569
575
  idempotencyIndex.set(idempotencyKey, runId);
@@ -571,9 +577,10 @@ export function createRunLifecycle(requiredArgs, optionalArgs = {}) {
571
577
  // its own — without this, a restart would re-populate `runs` with the daemon's entire terminal
572
578
  // run history on every boot, reproducing the same unbounded growth this module now guards
573
579
  // against at runtime.
574
- if (isTerminal)
575
- trackTerminalRun(runId, record, record.status.endedAt ?? Date.now());
576
- return isTerminal ? 'terminal' : 'non-terminal';
580
+ if (terminalAt === undefined)
581
+ return 'non-terminal';
582
+ trackTerminalRun(runId, record, terminalAt);
583
+ return 'terminal';
577
584
  }
578
585
  const lifecycle = {
579
586
  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. */