@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.
- package/CHANGELOG.md +8 -0
- package/dist/agent-executor/antigravity-permissions.d.ts +11 -0
- package/dist/agent-executor/antigravity-permissions.js +64 -0
- package/dist/agent-executor/contracts.d.ts +31 -6
- package/dist/agent-executor/event-translation.d.ts +6 -1
- package/dist/agent-executor/event-translation.js +1 -1
- package/dist/agent-executor/events.d.ts +1 -0
- package/dist/agent-executor/events.js +85 -74
- package/dist/agent-executor/launch.d.ts +4 -2
- package/dist/agent-executor/launch.js +306 -217
- package/dist/agent-executor/mcp-bridge-probe.js +51 -35
- package/dist/agent-executor/mcp-bridge.d.ts +18 -8
- package/dist/agent-executor/mcp-bridge.js +105 -65
- package/dist/agent-executor/run-output.d.ts +20 -0
- package/dist/agent-executor/run-output.js +80 -0
- package/dist/attachment-content.js +18 -15
- package/dist/core/read-only-tools.d.ts +26 -3
- package/dist/core/read-only-tools.js +38 -5
- package/dist/core/tool-audit.d.ts +3 -1
- package/dist/core/tool-audit.js +35 -22
- package/dist/core/tool-candidates.d.ts +8 -0
- package/dist/core/tool-candidates.js +6 -0
- package/dist/core/tool-gaps.d.ts +60 -0
- package/dist/core/tool-gaps.js +199 -0
- package/dist/delegated-tool-bridge.d.ts +1 -1
- package/dist/delegated-tool-bridge.js +161 -122
- package/dist/event-log.d.ts +45 -2
- package/dist/event-log.js +83 -4
- package/dist/http/attachments.d.ts +26 -1
- package/dist/http/attachments.js +129 -65
- package/dist/http/connectors.d.ts +1 -32
- package/dist/http/connectors.js +13 -68
- package/dist/http/delegated-tools.d.ts +5 -1
- package/dist/http/delegated-tools.js +29 -29
- package/dist/http/host-tools.d.ts +1 -0
- package/dist/http/host-tools.js +64 -59
- package/dist/http/index.d.ts +2 -2
- package/dist/http/index.js +1 -1
- package/dist/http/media.js +48 -69
- package/dist/http/memory.js +45 -36
- package/dist/http/model-proxy-fields.d.ts +23 -0
- package/dist/http/model-proxy-fields.js +29 -0
- package/dist/http/model-proxy.js +193 -395
- package/dist/http/research-tavily.adapter.js +72 -49
- package/dist/http/research.js +33 -23
- package/dist/http/resource-request.d.ts +37 -0
- package/dist/http/resource-request.js +59 -0
- package/dist/http/routines.js +49 -90
- package/dist/http/runs.d.ts +7 -5
- package/dist/http/runs.js +21 -7
- package/dist/http/terminals.js +23 -48
- package/dist/http/tool-catalog.d.ts +5 -0
- package/dist/http/xai.d.ts +11 -1
- package/dist/http/xai.js +125 -124
- package/dist/legacy-data-migration.js +41 -24
- package/dist/run/core/retry.js +51 -35
- package/dist/run/diagnostics/diagnostics.js +5 -17
- package/dist/run-credentials.js +49 -27
- package/dist/run-lifecycle.d.ts +12 -2
- package/dist/run-lifecycle.js +47 -26
- package/dist/store/agent-sessions/sqlite.d.ts +6 -0
- package/dist/store/agent-sessions/sqlite.js +6 -4
- package/dist/surface-exchanges.js +2 -1
- package/dist/terminal-session.d.ts +1 -1
- package/dist/terminal-session.js +26 -33
- package/dist/tool-audit.d.ts +1 -0
- package/dist/tool-audit.js +1 -0
- package/dist/tool-executor.d.ts +24 -3
- package/dist/tool-executor.js +277 -139
- package/dist/tool-recovery/recovery.js +64 -35
- package/dist/tool-recovery/stack.d.ts +2 -1
- package/dist/tool-recovery/stack.js +1 -1
- package/package.json +10 -10
package/dist/run/core/retry.js
CHANGED
|
@@ -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
|
|
171
|
-
const
|
|
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
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
218
|
+
const reason = retrySuppressedReason({
|
|
219
|
+
result: requiredArgs.result,
|
|
220
|
+
failure: optionalArgs.failure,
|
|
221
|
+
sideEffects: optionalArgs.sideEffects ?? {},
|
|
222
|
+
attemptCount,
|
|
223
|
+
retryMaxAttempts,
|
|
183
224
|
});
|
|
184
|
-
if (
|
|
185
|
-
return
|
|
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
|
|
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
|
|
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',
|
|
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',
|
|
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 =
|
|
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 =
|
|
133
|
+
const chunk = readStreamChunk(event.data);
|
|
146
134
|
if (chunk) {
|
|
147
135
|
observed.stdout += chunk;
|
|
148
136
|
observed.userVisibleOutputSeen = true;
|
package/dist/run-credentials.js
CHANGED
|
@@ -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
|
-
/**
|
|
87
|
-
|
|
88
|
-
|
|
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
|
|
104
|
-
return
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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 {
|
package/dist/run-lifecycle.d.ts
CHANGED
|
@@ -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
|
package/dist/run-lifecycle.js
CHANGED
|
@@ -108,7 +108,9 @@ function rehydratedRunRecord(runId, entries) {
|
|
|
108
108
|
retentionTimer: undefined,
|
|
109
109
|
},
|
|
110
110
|
idempotencyKey,
|
|
111
|
-
|
|
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
|
-
*
|
|
458
|
-
*
|
|
459
|
-
*
|
|
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
|
-
* -
|
|
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
|
-
* -
|
|
467
|
-
* which happens strictly before the watchdog is armed a few lines later
|
|
468
|
-
*
|
|
469
|
-
*
|
|
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
|
|
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
|
-
*
|
|
514
|
-
*
|
|
515
|
-
*
|
|
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
|
|
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,
|
|
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 (
|
|
575
|
-
|
|
576
|
-
|
|
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,
|
|
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,
|
|
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(
|
|
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. */
|