@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.
- package/CHANGELOG.md +148 -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 +37 -6
- package/dist/agent-executor/event-translation.d.ts +6 -1
- package/dist/agent-executor/event-translation.js +3 -1
- package/dist/agent-executor/events.d.ts +5 -0
- package/dist/agent-executor/events.js +143 -87
- package/dist/agent-executor/launch.d.ts +5 -3
- package/dist/agent-executor/launch.js +315 -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 +15 -0
- package/dist/core/read-only-tools.js +29 -1
- package/dist/core/tool-audit.d.ts +3 -1
- package/dist/core/tool-audit.js +35 -22
- package/dist/core/tool-gaps.d.ts +60 -0
- package/dist/core/tool-gaps.js +199 -0
- package/dist/delegated-result-correlation.d.ts +26 -0
- package/dist/delegated-result-correlation.js +68 -0
- package/dist/delegated-tool-bridge.d.ts +6 -14
- package/dist/delegated-tool-bridge.js +155 -155
- 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 +8 -1
- package/dist/http/delegated-tools.js +30 -30
- 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/index.d.ts +2 -0
- package/dist/index.js +1 -0
- 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 +31 -2
- package/dist/run-lifecycle.js +49 -42
- 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 +276 -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/dist/tool-result-diagnostics.d.ts +27 -0
- package/dist/tool-result-diagnostics.js +77 -0
- package/dist/tool-result-media.d.ts +27 -14
- package/dist/tool-result-media.js +51 -38
- package/package.json +14 -14
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
|
@@ -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
|
package/dist/run-lifecycle.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
*
|
|
458
|
-
*
|
|
459
|
-
*
|
|
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
|
-
* -
|
|
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
|
-
* -
|
|
467
|
-
* which happens strictly before the watchdog is armed a few lines later
|
|
468
|
-
*
|
|
469
|
-
*
|
|
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
|
|
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
|
-
*
|
|
514
|
-
*
|
|
515
|
-
*
|
|
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
|
|
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,
|
|
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 (
|
|
575
|
-
|
|
576
|
-
|
|
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,
|
|
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. */
|