@zq-silk/yui 0.15.0 → 0.15.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -9
- package/dist/cli/commandCatalog.js +5 -5
- package/dist/cli.js +5 -3
- package/dist/commands/agentCommands.js +13 -6
- package/dist/commands/globalRoleCommands.js +11 -3
- package/dist/commands/roleConfiguration.js +7 -0
- package/dist/commands/roleRuntimeGuard.js +30 -0
- package/dist/commands/taskCommands.js +10 -3
- package/dist/context/sessionBootstrapManifest.js +24 -9
- package/dist/controller/fileSchedulerStoreAdapter.js +4 -4
- package/dist/controller/jobClient.js +1 -0
- package/dist/controller/jobControl.js +137 -10
- package/dist/controller/jobSupervisor.js +89 -75
- package/dist/controller/runtime.js +19 -28
- package/dist/controller/runtimeLaunchCoordinator.js +9 -30
- package/dist/controller/sessionNotify.js +5 -0
- package/dist/core/boundedRpc.js +3 -1
- package/dist/executor/agentExecutor.js +8 -11
- package/dist/executor/effectiveLaunch.js +34 -17
- package/dist/executor/fileRoleLaunchPlanner.js +11 -8
- package/dist/job/durableJob.js +68 -6
- package/dist/kernel/callAuthority.js +24 -0
- package/dist/kernel/instanceHost.js +97 -0
- package/dist/kernel/kernelPorts.js +44 -0
- package/dist/kernel/operationFacts.js +32 -0
- package/dist/runtime/agentHost.js +7 -0
- package/dist/runtime/codexInteractiveHost.js +191 -0
- package/dist/runtime/exactControlPlane.js +8 -10
- package/dist/runtime/structuredProviderHost.js +35 -0
- package/dist/runtime/tmuxAdapters.js +51 -9
- package/dist/scheduler/activeRoleTurnDelivery.js +4 -4
- package/dist/scheduler/leaderWakeupProcessor.js +3 -4
- package/dist/storage/sqliteSchema.js +28 -0
- package/dist/storage/sqliteStore.js +11 -3
- package/dist/storage/storageVersions.js +1 -1
- package/dist/storage/storeRpc.js +1 -1
- package/dist/storage/taskStore.js +6 -1
- package/dist/storage/upgrade/upgradeOrchestrator.js +3 -0
- package/dist/tmux/tmuxManager.js +43 -28
- package/i18n/README.zh-CN.md +7 -0
- package/package.json +1 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { runtimeLifecycleTarget } from "../runtime/lifecycleReservation.js";
|
|
3
3
|
import { createRuntimeBinding, RuntimeGenerationMismatchError, RuntimeHostContentionError, RuntimeLaunchError } from "../runtime/index.js";
|
|
4
|
-
import {
|
|
4
|
+
import { sameEffectiveLaunch, validateEffectiveLaunchSnapshot } from "../executor/effectiveLaunch.js";
|
|
5
5
|
class RuntimeBindingContractError extends Error {
|
|
6
6
|
constructor(message, options) {
|
|
7
7
|
super(message, options);
|
|
@@ -25,7 +25,6 @@ export class RuntimeLaunchCoordinator {
|
|
|
25
25
|
#createGenerationId;
|
|
26
26
|
#now;
|
|
27
27
|
#assertCurrent;
|
|
28
|
-
#launchFingerprint;
|
|
29
28
|
#onCleanupRequired;
|
|
30
29
|
#runtimeIsolation;
|
|
31
30
|
constructor(reservations, host, options = {}) {
|
|
@@ -34,8 +33,6 @@ export class RuntimeLaunchCoordinator {
|
|
|
34
33
|
this.#createGenerationId = options.createGenerationId ?? randomUUID;
|
|
35
34
|
this.#now = options.now ?? (() => new Date());
|
|
36
35
|
this.#assertCurrent = options.assertCurrent;
|
|
37
|
-
this.#launchFingerprint = options.launchFingerprint
|
|
38
|
-
?? defaultLaunchFingerprint;
|
|
39
36
|
this.#onCleanupRequired = options.onCleanupRequired;
|
|
40
37
|
this.#runtimeIsolation = options.runtimeIsolation;
|
|
41
38
|
}
|
|
@@ -79,22 +76,19 @@ export class RuntimeLaunchCoordinator {
|
|
|
79
76
|
if (request.owner.scope === "global" && request.managedWorkspace !== undefined) {
|
|
80
77
|
throw new Error("A global runtime cannot use a Task ManagedWorkspace.");
|
|
81
78
|
}
|
|
82
|
-
const expectedFingerprint = requireText(this.#launchFingerprint(request), "Launch fingerprint");
|
|
83
79
|
const assertLaunchCurrent = () => {
|
|
84
80
|
this.#assertCurrent?.(request);
|
|
85
81
|
assertCurrent?.();
|
|
86
|
-
if (this.#launchFingerprint(request) !== expectedFingerprint) {
|
|
87
|
-
throw new Error(`Role or Agent launch state changed: ${request.owner.roleName}.`);
|
|
88
|
-
}
|
|
89
82
|
};
|
|
90
|
-
const generationPrefix = `runtime-${expectedFingerprint}:generation:`;
|
|
91
83
|
const proposedGenerationId = requireText(this.#createGenerationId(), "Launch generation id");
|
|
92
|
-
|
|
84
|
+
// A Host activation id is an opaque durable identity, not a digest of the
|
|
85
|
+
// launch configuration. Whether a live activation may be reused is answered
|
|
86
|
+
// by the Role's durable Session record through `assertCurrent`, so launch
|
|
87
|
+
// configuration that only shapes the next activation never invalidates the
|
|
88
|
+
// current one.
|
|
89
|
+
let proposedRuntimeGenerationId = `runtime-${proposedGenerationId}`;
|
|
93
90
|
let reusedConfirmedRunningHost = false;
|
|
94
91
|
if (request.mode === "resume" && request.hostActivationId !== undefined) {
|
|
95
|
-
if (!request.hostActivationId.startsWith(generationPrefix)) {
|
|
96
|
-
throw new Error("Session restore targets an incompatible Host activation.");
|
|
97
|
-
}
|
|
98
92
|
const inspection = await this.host.inspectOwner(request.owner);
|
|
99
93
|
if (inspection.state === "unavailable" || inspection.state === "starting") {
|
|
100
94
|
throw new RuntimeLaunchError(true, request.hostActivationId, `Host activation is temporarily ${inspection.state}: ${request.owner.roleName}.`);
|
|
@@ -112,10 +106,6 @@ export class RuntimeLaunchCoordinator {
|
|
|
112
106
|
runtimeGenerationId: proposedRuntimeGenerationId
|
|
113
107
|
}, assertLaunchCurrent, this.#now());
|
|
114
108
|
if (reservation.status === "existing") {
|
|
115
|
-
if (!reservation.runtimeGenerationId.startsWith(generationPrefix)) {
|
|
116
|
-
this.#requireCleanup(request.owner);
|
|
117
|
-
throw new Error(`Runtime launch reservation belongs to stale Role or Agent state: ${request.owner.roleName}.`);
|
|
118
|
-
}
|
|
119
109
|
const inspection = await this.host.inspectOwner(request.owner);
|
|
120
110
|
if (inspection.state === "unavailable" || inspection.state === "starting") {
|
|
121
111
|
throw new RuntimeLaunchError(true, reservation.runtimeGenerationId, `Runtime is temporarily ${inspection.state}: ${request.owner.roleName}/${reservation.runtimeGenerationId}.`);
|
|
@@ -416,7 +406,7 @@ function validateRuntimeLaunchPreflight(preflight, request, runtimeGenerationId)
|
|
|
416
406
|
|| preflight.turnId !== request.turnId
|
|
417
407
|
|| preflight.agentId !== request.agentId
|
|
418
408
|
|| preflight.adapterId !== request.adapterId
|
|
419
|
-
|| !
|
|
409
|
+
|| !sameEffectiveLaunch(preflight.effective, request.effective)
|
|
420
410
|
|| (request.mode === "resume"
|
|
421
411
|
&& preflight.nativeSessionId !== request.nativeSessionId)) {
|
|
422
412
|
throw new Error(`Session host pre-start launch fence does not match the requested runtime: ${request.owner.roleName}.`);
|
|
@@ -445,17 +435,6 @@ function requireMatchingRuntimeBinding(raw, request, runtimeGenerationId) {
|
|
|
445
435
|
}
|
|
446
436
|
return binding;
|
|
447
437
|
}
|
|
448
|
-
function defaultLaunchFingerprint(request) {
|
|
449
|
-
return createHash("sha256").update(JSON.stringify([
|
|
450
|
-
request.owner,
|
|
451
|
-
request.agentId,
|
|
452
|
-
request.adapterId,
|
|
453
|
-
request.effective,
|
|
454
|
-
request.workspace,
|
|
455
|
-
request.managedWorkspace,
|
|
456
|
-
request.runtimePolicy
|
|
457
|
-
])).digest("hex");
|
|
458
|
-
}
|
|
459
438
|
function requireText(value, label) {
|
|
460
439
|
if (typeof value !== "string"
|
|
461
440
|
|| value.length === 0
|
|
@@ -7,6 +7,11 @@ import { openCurrentTaskStore } from "../storage/currentTaskStore.js";
|
|
|
7
7
|
/** Hidden CLI entrypoint used by Codex's structured notify hook. */
|
|
8
8
|
export async function runSessionNotifyCommand(payloadArgument, environment = process.env, call, setThreadName = setCodexThreadName) {
|
|
9
9
|
const params = parseCodexSessionNotification(payloadArgument, environment);
|
|
10
|
+
// Old global TUIs can still emit their invocation-local hook. It is not
|
|
11
|
+
// evidence of a shared-daemon Thread's identity or process lifecycle.
|
|
12
|
+
// Global identity is committed from App Server at successful host start.
|
|
13
|
+
if (params.scope === "global")
|
|
14
|
+
return;
|
|
10
15
|
const home = requireText(environment.YUI_HOME, "YUI_HOME");
|
|
11
16
|
// A Codex process outlives its Turn, so the notify envelope cannot say which
|
|
12
17
|
// Turn or runtime generation is current. Durable Session state answers both;
|
package/dist/core/boundedRpc.js
CHANGED
|
@@ -33,7 +33,9 @@ export function serializeError(error) {
|
|
|
33
33
|
...(error.stack === undefined ? {} : { stack: error.stack }),
|
|
34
34
|
...("code" in error && typeof error.code === "string"
|
|
35
35
|
? { code: error.code }
|
|
36
|
-
: {})
|
|
36
|
+
: {}),
|
|
37
|
+
...("currentRevision" in error && typeof error.currentRevision === "number"
|
|
38
|
+
? { currentRevision: error.currentRevision } : {})
|
|
37
39
|
};
|
|
38
40
|
}
|
|
39
41
|
return { name: "Error", message: String(error) };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { hasRecentTurnId, rememberRecentTurnId, validateRecentTurnIds } from "../runtime/recentTurnIds.js";
|
|
3
|
-
import {
|
|
3
|
+
import { roleSessionMayContinue, validateEffectiveLaunchSnapshot } from "./effectiveLaunch.js";
|
|
4
4
|
import { currentProviderActivation, endProviderActivation, settleProviderTurn, settleProviderTurnSubmission, validateProviderRuntimeBinding } from "../runtime/providerRuntimeIdentity.js";
|
|
5
5
|
import { builtinDriverIdForAdapter } from "../runtime/builtinAgentDrivers.js";
|
|
6
6
|
export function createRoleSessionSet(owner, activeAgentId, now) {
|
|
@@ -66,10 +66,9 @@ export function recordRoleAgentSession(set, input, now) {
|
|
|
66
66
|
throw new Error(`Role Agent session effective identity is inconsistent: ${agentId}.`);
|
|
67
67
|
}
|
|
68
68
|
if (existing !== undefined && existing.nativeSessionId === nativeSessionId
|
|
69
|
-
&& !
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
throw new Error(`Role Agent session effective launch cannot change: ${agentId}.`);
|
|
69
|
+
&& !roleSessionMayContinue(existing.effective, effective)) {
|
|
70
|
+
throw new Error(`Role Agent session cannot continue under this launch: ${agentId}. `
|
|
71
|
+
+ "Its Agent, adapter or physical workspace changed.");
|
|
73
72
|
}
|
|
74
73
|
if (existing !== undefined && existing.nativeSessionId !== nativeSessionId
|
|
75
74
|
&& existing.status === "active") {
|
|
@@ -280,13 +279,11 @@ export function roleAgentSessionResumeMode(set, agentId, desired) {
|
|
|
280
279
|
if (session.status === "ended") {
|
|
281
280
|
return "new";
|
|
282
281
|
}
|
|
283
|
-
|
|
284
|
-
? effectiveLaunchSnapshotsCompatibleForTaskSession(session.effective, desired)
|
|
285
|
-
: effectiveLaunchSnapshotsCompatible(session.effective, desired);
|
|
286
|
-
if (compatible)
|
|
282
|
+
if (roleSessionMayContinue(session.effective, desired))
|
|
287
283
|
return "resume";
|
|
288
|
-
throw new Error(`Role Agent session
|
|
289
|
-
+ "
|
|
284
|
+
throw new Error(`Role Agent session cannot continue under the next launch: ${agentId}. `
|
|
285
|
+
+ "Its Agent, adapter or physical workspace changed; stop the existing native "
|
|
286
|
+
+ "process before starting a fresh Session.");
|
|
290
287
|
}
|
|
291
288
|
export function bindTaskRoleProviderRuntime(set, binding, updatedAt) {
|
|
292
289
|
validateRoleSessionSet(set);
|
|
@@ -69,7 +69,12 @@ function claudeConfigFromSnapshot(snapshot) {
|
|
|
69
69
|
: { settingsSources: [...snapshot.settingsSources] })
|
|
70
70
|
};
|
|
71
71
|
}
|
|
72
|
-
|
|
72
|
+
/**
|
|
73
|
+
* Exactness fence for one launch: the same resolved launch must be observed by
|
|
74
|
+
* every participant of that launch. Desired-revision bookkeeping is provenance
|
|
75
|
+
* and never part of the resolved launch itself.
|
|
76
|
+
*/
|
|
77
|
+
export function sameEffectiveLaunch(existing, desired) {
|
|
73
78
|
validateEffectiveLaunchSnapshot(existing);
|
|
74
79
|
validateEffectiveLaunchSnapshot(desired);
|
|
75
80
|
const withoutDesiredRevision = (snapshot) => {
|
|
@@ -79,20 +84,24 @@ export function effectiveLaunchSnapshotsCompatible(existing, desired) {
|
|
|
79
84
|
return isDeepStrictEqual(withoutDesiredRevision(existing), withoutDesiredRevision(desired));
|
|
80
85
|
}
|
|
81
86
|
/**
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
87
|
+
* Whether a live native Session can still serve the next launch request.
|
|
88
|
+
*
|
|
89
|
+
* Only facts that make continuation impossible participate: the Session
|
|
90
|
+
* protocol, the provider identity that owns the conversation, and the physical
|
|
91
|
+
* workspace the Session runs in. Launch configuration such as model, effort,
|
|
92
|
+
* permission, Role context, declared write scope, and Turn-scoped facts like
|
|
93
|
+
* ReviewRound identity or candidate commits shape the next Host activation
|
|
94
|
+
* instead of ending the Session; that divergence is acknowledged where the
|
|
95
|
+
* configuration changes and stays visible as launch provenance.
|
|
96
|
+
*
|
|
97
|
+
* Session kind needs no separate check: a Role's review Turns run in their own
|
|
98
|
+
* ReviewRound workspace, so the physical workspace already separates a review
|
|
99
|
+
* Session from an execution Session.
|
|
86
100
|
*/
|
|
87
|
-
export function
|
|
88
|
-
if (effectiveLaunchSnapshotsCompatible(existing, desired))
|
|
89
|
-
return true;
|
|
101
|
+
export function roleSessionMayContinue(existing, desired) {
|
|
90
102
|
validateEffectiveLaunchSnapshot(existing);
|
|
91
103
|
validateEffectiveLaunchSnapshot(desired);
|
|
92
|
-
|
|
93
|
-
return false;
|
|
94
|
-
}
|
|
95
|
-
return isDeepStrictEqual(taskSessionCompatibleSnapshot(existing), taskSessionCompatibleSnapshot(desired));
|
|
104
|
+
return isDeepStrictEqual(sessionContinuitySnapshot(existing), sessionContinuitySnapshot(desired));
|
|
96
105
|
}
|
|
97
106
|
/** Preserves a fixed Session's launch configuration while freezing fresh Task-main Git facts. */
|
|
98
107
|
export function effectiveLaunchWithTaskMainWorkspace(existing, workspace) {
|
|
@@ -109,13 +118,21 @@ export function effectiveLaunchWithTaskMainWorkspace(existing, workspace) {
|
|
|
109
118
|
}
|
|
110
119
|
});
|
|
111
120
|
}
|
|
112
|
-
function
|
|
113
|
-
const { sourceDesiredRevision: _sourceDesiredRevision, reviewRoundId: _reviewRoundId, reviewBaseCommit: _reviewBaseCommit, workspace, ...launch } = snapshot;
|
|
121
|
+
function sessionContinuitySnapshot(snapshot) {
|
|
114
122
|
return {
|
|
115
|
-
|
|
123
|
+
schemaVersion: snapshot.schemaVersion,
|
|
124
|
+
contextProtocolVersion: snapshot.contextProtocolVersion,
|
|
125
|
+
agentId: snapshot.agentId,
|
|
126
|
+
adapterId: snapshot.adapterId,
|
|
116
127
|
workspace: {
|
|
117
|
-
root: workspace.root,
|
|
118
|
-
entries: workspace.entries.map((
|
|
128
|
+
root: snapshot.workspace.root,
|
|
129
|
+
entries: snapshot.workspace.entries.map((entry) => ({
|
|
130
|
+
projectId: entry.projectId,
|
|
131
|
+
directory: entry.directory,
|
|
132
|
+
access: entry.access,
|
|
133
|
+
path: entry.path,
|
|
134
|
+
branch: entry.branch
|
|
135
|
+
}))
|
|
119
136
|
}
|
|
120
137
|
};
|
|
121
138
|
}
|
|
@@ -15,7 +15,7 @@ import { resolveTaskRoleSessionTitle } from "../runtime/sessionTitle.js";
|
|
|
15
15
|
import { nativeSessionIdForLaunch } from "../runtime/preallocatedNativeSession.js";
|
|
16
16
|
import { classifyWorkspacePreflight, formatWorkspacePreflightError } from "./workspacePreflightClassification.js";
|
|
17
17
|
import { activeLiveRoleAgentSession } from "./agentExecutor.js";
|
|
18
|
-
import {
|
|
18
|
+
import { roleSessionMayContinue, effectiveRoleForLaunch, resolveEffectiveLaunch } from "./effectiveLaunch.js";
|
|
19
19
|
import { YUI_CONTROL_PLANE_DESCRIPTOR, createExactControlPlaneDescriptor, serializeExactDescriptor } from "../runtime/exactControlPlane.js";
|
|
20
20
|
import { detectRunningRelease } from "../release/runtimeRelease.js";
|
|
21
21
|
import { parseTaskRuntimeIsolationDescriptor, taskRuntimeIsolationEnvironment } from "../runtime/taskRuntimeIsolation.js";
|
|
@@ -176,9 +176,7 @@ export class FileRoleLaunchPlanner {
|
|
|
176
176
|
const effective = input.effective ?? resolvedEffective;
|
|
177
177
|
const existing = sessionSet?.sessions[effective.agentId];
|
|
178
178
|
const compatibleExisting = existing !== undefined
|
|
179
|
-
&& (
|
|
180
|
-
? effectiveLaunchSnapshotsCompatibleForTaskSession(existing.effective, effective)
|
|
181
|
-
: effectiveLaunchSnapshotsCompatible(existing.effective, effective));
|
|
179
|
+
&& roleSessionMayContinue(existing.effective, effective);
|
|
182
180
|
if (input.mode === "resume" && !compatibleExisting) {
|
|
183
181
|
throw new Error(`Task Role resume effective snapshot drifted: ${task.id}/${role.name}.`);
|
|
184
182
|
}
|
|
@@ -204,7 +202,7 @@ export class FileRoleLaunchPlanner {
|
|
|
204
202
|
const effective = input.effective ?? resolvedEffective;
|
|
205
203
|
const existing = sessionSet?.sessions[effective.agentId];
|
|
206
204
|
const compatibleExisting = existing !== undefined
|
|
207
|
-
&&
|
|
205
|
+
&& roleSessionMayContinue(existing.effective, effective);
|
|
208
206
|
if (input.mode === "resume" && !compatibleExisting) {
|
|
209
207
|
throw new Error(`Global Role resume effective snapshot drifted: ${role.name}.`);
|
|
210
208
|
}
|
|
@@ -246,7 +244,9 @@ export class FileRoleLaunchPlanner {
|
|
|
246
244
|
: undefined,
|
|
247
245
|
trustWorkspace: true
|
|
248
246
|
});
|
|
249
|
-
assertCodexLaunchOverridesAvailable(codexConfig,
|
|
247
|
+
assertCodexLaunchOverridesAvailable(codexConfig, owner.scope === "global"
|
|
248
|
+
? ["developerInstructions"]
|
|
249
|
+
: ["developerInstructions", "notify"]);
|
|
250
250
|
}
|
|
251
251
|
const runtimeIsolation = input.runtimeIsolation === undefined
|
|
252
252
|
? undefined
|
|
@@ -365,10 +365,10 @@ export class FileRoleLaunchPlanner {
|
|
|
365
365
|
args.push("--plugin-dir", ensureClaudeLifecyclePlugin(this.home, this.#cliPath));
|
|
366
366
|
}
|
|
367
367
|
if (binding.adapterId === "codex") {
|
|
368
|
-
//
|
|
368
|
+
// Interactive Task sessions may use notify for presentation.
|
|
369
369
|
// Managed Turns receive lifecycle facts through their ordinary App Server
|
|
370
370
|
// subscription, avoiding a second Hook channel for the same Turn.
|
|
371
|
-
if (owner.scope
|
|
371
|
+
if (owner.scope === "task" && input.turnId === undefined) {
|
|
372
372
|
args = addCodexSessionNotify(args, launchMode, this.#cliPath);
|
|
373
373
|
}
|
|
374
374
|
// Managed Codex Turns use disposable proxy clients against the shared
|
|
@@ -472,6 +472,9 @@ export class FileRoleLaunchPlanner {
|
|
|
472
472
|
YUI_WORKSPACE: effectiveWorkspace,
|
|
473
473
|
YUI_SESSION_MANIFEST: sessionContext.sessionManifestPath,
|
|
474
474
|
YUI_SESSION_CLI: sessionContext.sessionCliPath,
|
|
475
|
+
...(owner.scope === "global" && configured.adapterId === "codex"
|
|
476
|
+
? { YUI_AGENT_BASE_ARGS: JSON.stringify(configured.baseArgs) }
|
|
477
|
+
: {}),
|
|
475
478
|
...(jobCallerKey === undefined ? {} : { YUI_JOB_CALLER_KEY: jobCallerKey }),
|
|
476
479
|
...(owner.scope !== "task"
|
|
477
480
|
? {}
|
package/dist/job/durableJob.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { requireIdentity, requirePositiveInteger, requireText, requireTimestamp } from "../domain/validation.js";
|
|
3
3
|
import { validateTaskRecordReference } from "../task/taskRecordReference.js";
|
|
4
|
-
|
|
4
|
+
import { recordOperationEvidence, validateOperationFacts } from "../kernel/operationFacts.js";
|
|
5
|
+
export const CURRENT_DURABLE_JOB_SCHEMA_VERSION = 2;
|
|
6
|
+
export const JOB_RUNNER_IMPLEMENTATION = Object.freeze({ id: "yui:job-runner", generation: "1" });
|
|
5
7
|
export const DURABLE_JOB_TERMINAL_STATUSES = [
|
|
6
8
|
"succeeded",
|
|
7
9
|
"failed",
|
|
@@ -14,7 +16,7 @@ export function isDurableJobTerminal(status) {
|
|
|
14
16
|
}
|
|
15
17
|
export function createDurableJob(input, now) {
|
|
16
18
|
const timestamp = now.toISOString();
|
|
17
|
-
const
|
|
19
|
+
const contentKey = input.retryOf === undefined
|
|
18
20
|
? durableJobIdempotencyKey({
|
|
19
21
|
owner: input.owner,
|
|
20
22
|
projectId: input.projectId,
|
|
@@ -31,6 +33,10 @@ export function createDurableJob(input, now) {
|
|
|
31
33
|
workspace: input.workspace,
|
|
32
34
|
env: input.env
|
|
33
35
|
}), input.retryOf);
|
|
36
|
+
const idempotencyKey = input.operation === undefined ? contentKey
|
|
37
|
+
: createHash("sha256").update(JSON.stringify([
|
|
38
|
+
input.operation.actorId, input.operation.requestId
|
|
39
|
+
])).digest("hex");
|
|
34
40
|
return validateDurableJob({
|
|
35
41
|
schemaVersion: CURRENT_DURABLE_JOB_SCHEMA_VERSION,
|
|
36
42
|
id: input.id,
|
|
@@ -42,6 +48,18 @@ export function createDurableJob(input, now) {
|
|
|
42
48
|
env: { ...input.env },
|
|
43
49
|
steps: input.steps.map((step) => ({ ...step })),
|
|
44
50
|
idempotencyKey,
|
|
51
|
+
operation: {
|
|
52
|
+
requestId: input.operation?.requestId ?? idempotencyKey,
|
|
53
|
+
inputDigest: input.operation?.inputDigest ?? idempotencyKey,
|
|
54
|
+
actorId: input.operation?.actorId ?? "internal:job",
|
|
55
|
+
authorityRef: input.operation?.authorityRef ?? "internal:job",
|
|
56
|
+
targetId: input.workspace,
|
|
57
|
+
capability: "job.start",
|
|
58
|
+
implementation: JOB_RUNNER_IMPLEMENTATION,
|
|
59
|
+
effect: "none",
|
|
60
|
+
receiptRefs: [],
|
|
61
|
+
partialResultRefs: []
|
|
62
|
+
},
|
|
45
63
|
status: "queued",
|
|
46
64
|
artifactsLocator: input.artifactsLocator,
|
|
47
65
|
createdAt: timestamp,
|
|
@@ -58,6 +76,10 @@ export function startDurableJob(job, process, now) {
|
|
|
58
76
|
return validateDurableJob({
|
|
59
77
|
...job,
|
|
60
78
|
status: "running",
|
|
79
|
+
operation: recordOperationEvidence(job.operation, {
|
|
80
|
+
effect: "confirmed",
|
|
81
|
+
receiptRefs: [`${job.artifactsLocator}/start.json`]
|
|
82
|
+
}),
|
|
61
83
|
process: { ...process },
|
|
62
84
|
startedAt: timestamp,
|
|
63
85
|
heartbeatAt: timestamp,
|
|
@@ -87,20 +109,46 @@ export function completeDurableJob(job, result, now) {
|
|
|
87
109
|
return validateDurableJob({
|
|
88
110
|
...job,
|
|
89
111
|
status: result.outcome,
|
|
112
|
+
operation: recordOperationEvidence(job.operation, {
|
|
113
|
+
partialResultRefs: result.steps.map((step) => step.logPath),
|
|
114
|
+
receiptRefs: result.evidenceSource === undefined ? []
|
|
115
|
+
: [`${job.artifactsLocator}/${result.evidenceSource === "checkpoint" ? "checkpoint.json" : "exit.json"}`]
|
|
116
|
+
}),
|
|
90
117
|
result: normalizeDurableJobResult(result),
|
|
91
118
|
terminalAt: timestamp,
|
|
92
119
|
updatedAt: timestamp
|
|
93
120
|
});
|
|
94
121
|
}
|
|
122
|
+
/** A refused, unattempted request has a known failed outcome, not an unknown effect. */
|
|
123
|
+
export function rejectQueuedDurableJob(job, reason, now) {
|
|
124
|
+
validateDurableJob(job);
|
|
125
|
+
if (job.status !== "queued" || job.operation.effect !== "none") {
|
|
126
|
+
throw new Error("Only an unattempted queued Job can be rejected.");
|
|
127
|
+
}
|
|
128
|
+
const timestamp = now.toISOString();
|
|
129
|
+
return validateDurableJob({
|
|
130
|
+
...job,
|
|
131
|
+
status: "failed",
|
|
132
|
+
result: {
|
|
133
|
+
outcome: "failed", exitCode: null, signal: null,
|
|
134
|
+
unknownReason: requireText(reason, "DurableJob rejection reason"), steps: []
|
|
135
|
+
},
|
|
136
|
+
terminalAt: timestamp,
|
|
137
|
+
updatedAt: timestamp
|
|
138
|
+
});
|
|
139
|
+
}
|
|
95
140
|
export function markDurableJobUnknown(job, unknownReason, completedSteps, now) {
|
|
96
141
|
validateDurableJob(job);
|
|
97
|
-
if (job.status !== "running") {
|
|
98
|
-
throw new Error(`DurableJob can only be marked unknown
|
|
142
|
+
if (job.status !== "running" && !(job.status === "queued" && job.operation.effect !== "none")) {
|
|
143
|
+
throw new Error(`DurableJob can only be marked unknown after an attempted effect: ${job.status}.`);
|
|
99
144
|
}
|
|
100
145
|
const timestamp = now.toISOString();
|
|
101
146
|
return validateDurableJob({
|
|
102
147
|
...job,
|
|
103
148
|
status: "unknown-needs-attention",
|
|
149
|
+
operation: recordOperationEvidence(job.operation, {
|
|
150
|
+
partialResultRefs: completedSteps.map((step) => step.logPath)
|
|
151
|
+
}),
|
|
104
152
|
result: {
|
|
105
153
|
outcome: "unknown-needs-attention",
|
|
106
154
|
exitCode: null,
|
|
@@ -205,6 +253,7 @@ export function validateDurableJob(job) {
|
|
|
205
253
|
if (job.schemaVersion !== CURRENT_DURABLE_JOB_SCHEMA_VERSION) {
|
|
206
254
|
throw new Error(`DurableJob must use schemaVersion ${CURRENT_DURABLE_JOB_SCHEMA_VERSION}.`);
|
|
207
255
|
}
|
|
256
|
+
validateOperationFacts(job.operation);
|
|
208
257
|
validateTaskRecordReference({
|
|
209
258
|
taskId: job.taskId,
|
|
210
259
|
localId: job.id
|
|
@@ -326,7 +375,15 @@ export function validDurableJobTransition(before, after) {
|
|
|
326
375
|
|| before.createdAt !== after.createdAt
|
|
327
376
|
|| !isDeepStrictEqual(before.owner, after.owner)
|
|
328
377
|
|| !isDeepStrictEqual(before.env, after.env)
|
|
329
|
-
|| !isDeepStrictEqual(before.steps, after.steps)
|
|
378
|
+
|| !isDeepStrictEqual(before.steps, after.steps)
|
|
379
|
+
|| before.operation.requestId !== after.operation.requestId
|
|
380
|
+
|| before.operation.inputDigest !== after.operation.inputDigest
|
|
381
|
+
|| before.operation.actorId !== after.operation.actorId
|
|
382
|
+
|| before.operation.authorityRef !== after.operation.authorityRef
|
|
383
|
+
|| before.operation.targetId !== after.operation.targetId
|
|
384
|
+
|| before.operation.capability !== after.operation.capability
|
|
385
|
+
|| !isDeepStrictEqual(before.operation.implementation, after.operation.implementation)
|
|
386
|
+
|| !isDeepStrictEqual(recordOperationEvidence(before.operation, after.operation), after.operation))
|
|
330
387
|
return false;
|
|
331
388
|
if (isDurableJobTerminal(before.status)) {
|
|
332
389
|
// A terminal job is immutable except for two one-way flags:
|
|
@@ -340,12 +397,17 @@ export function validDurableJobTransition(before, after) {
|
|
|
340
397
|
&& after.acknowledgedAt !== undefined;
|
|
341
398
|
return before.status === after.status
|
|
342
399
|
&& isDeepStrictEqual(before.result, after.result)
|
|
400
|
+
&& isDeepStrictEqual(before.operation, after.operation)
|
|
343
401
|
&& before.terminalAt === after.terminalAt
|
|
344
402
|
&& (before.wakeupNotified === after.wakeupNotified || wakeupFlip)
|
|
345
403
|
&& (before.acknowledgedAt === after.acknowledgedAt || acknowledgeFlip);
|
|
346
404
|
}
|
|
347
405
|
const allowed = {
|
|
348
|
-
queued: [
|
|
406
|
+
queued: [
|
|
407
|
+
"queued", "running", "cancelled", "unknown-needs-attention",
|
|
408
|
+
...(before.operation.effect === "none" && after.operation.effect === "none"
|
|
409
|
+
? ["failed"] : [])
|
|
410
|
+
],
|
|
349
411
|
running: [
|
|
350
412
|
"running",
|
|
351
413
|
"succeeded",
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export class CallAuthority {
|
|
2
|
+
authenticateCurrent;
|
|
3
|
+
#credentials = new WeakMap();
|
|
4
|
+
constructor(authenticateCurrent) {
|
|
5
|
+
this.authenticateCurrent = authenticateCurrent;
|
|
6
|
+
}
|
|
7
|
+
authenticate(credential, targetId) {
|
|
8
|
+
const actorId = this.authenticateCurrent(credential, targetId);
|
|
9
|
+
const context = Object.freeze({ actorId, targetId });
|
|
10
|
+
this.#credentials.set(context, credential);
|
|
11
|
+
return context;
|
|
12
|
+
}
|
|
13
|
+
/** Reauthenticate at each new action; a context or implementation handle is
|
|
14
|
+
* not a permanent grant. Domain-specific authorization remains in its owner.
|
|
15
|
+
*/
|
|
16
|
+
authorize(context, targetId) {
|
|
17
|
+
if (!this.#credentials.has(context) || context.targetId !== targetId) {
|
|
18
|
+
throw new Error("Untrusted or out-of-scope call context.");
|
|
19
|
+
}
|
|
20
|
+
const actorId = this.authenticateCurrent(this.#credentials.get(context), targetId);
|
|
21
|
+
if (actorId !== context.actorId)
|
|
22
|
+
throw new Error("Call authority changed.");
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
export class InstanceHost {
|
|
2
|
+
#instances = new Map();
|
|
3
|
+
#closed = false;
|
|
4
|
+
attach(implementation, value, ownedDisposers = []) {
|
|
5
|
+
if (this.#closed)
|
|
6
|
+
throw new Error("Instance Host is closed.");
|
|
7
|
+
const key = implementationKey(implementation);
|
|
8
|
+
if (this.#instances.has(key))
|
|
9
|
+
throw new Error(`Implementation already attached: ${key}.`);
|
|
10
|
+
let resolve;
|
|
11
|
+
let reject;
|
|
12
|
+
const drained = new Promise((yes, no) => { resolve = yes; reject = no; });
|
|
13
|
+
// A disposer can fail before detach's caller starts awaiting the drain.
|
|
14
|
+
void drained.catch(() => undefined);
|
|
15
|
+
const ref = Object.freeze({ ...implementation });
|
|
16
|
+
this.#instances.set(key, {
|
|
17
|
+
implementation: ref, value, references: 0, detached: false,
|
|
18
|
+
disposers: [...ownedDisposers], drained, resolve, reject
|
|
19
|
+
});
|
|
20
|
+
return ref;
|
|
21
|
+
}
|
|
22
|
+
acquire(implementation) {
|
|
23
|
+
const instance = this.#instances.get(implementationKey(implementation));
|
|
24
|
+
if (this.#closed || instance === undefined || instance.detached) {
|
|
25
|
+
throw new Error("Implementation unavailable.");
|
|
26
|
+
}
|
|
27
|
+
instance.references += 1;
|
|
28
|
+
let released = false;
|
|
29
|
+
return Object.freeze({
|
|
30
|
+
implementation: instance.implementation,
|
|
31
|
+
value: instance.value,
|
|
32
|
+
release: async () => {
|
|
33
|
+
if (released)
|
|
34
|
+
return;
|
|
35
|
+
released = true;
|
|
36
|
+
instance.references -= 1;
|
|
37
|
+
if (instance.detached && instance.references === 0)
|
|
38
|
+
await this.#dispose(instance);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
async use(implementation, call) {
|
|
43
|
+
const handle = this.acquire(implementation);
|
|
44
|
+
try {
|
|
45
|
+
return await call(handle.value, handle.implementation);
|
|
46
|
+
}
|
|
47
|
+
finally {
|
|
48
|
+
await handle.release();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** Stops acquisition immediately. Resolves only after all calls/Sessions release. */
|
|
52
|
+
detach(implementation) {
|
|
53
|
+
const instance = this.#instances.get(implementationKey(implementation));
|
|
54
|
+
if (instance === undefined)
|
|
55
|
+
throw new Error("Implementation is not attached.");
|
|
56
|
+
instance.detached = true;
|
|
57
|
+
if (instance.references === 0)
|
|
58
|
+
void this.#dispose(instance).catch(() => undefined);
|
|
59
|
+
return instance.drained;
|
|
60
|
+
}
|
|
61
|
+
async close() {
|
|
62
|
+
this.#closed = true;
|
|
63
|
+
const results = await Promise.allSettled([...this.#instances.values()].map((instance) => this.detach(instance.implementation)));
|
|
64
|
+
const errors = results.flatMap((result) => result.status === "rejected" ? [result.reason] : []);
|
|
65
|
+
if (errors.length)
|
|
66
|
+
throw new AggregateError(errors, "Instance cleanup failed.");
|
|
67
|
+
}
|
|
68
|
+
#dispose(instance) {
|
|
69
|
+
instance.disposal ??= (async () => {
|
|
70
|
+
const errors = [];
|
|
71
|
+
for (const dispose of [...instance.disposers].reverse()) {
|
|
72
|
+
try {
|
|
73
|
+
await dispose();
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
errors.push(error);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
// Keep the identity reserved for this Host's lifetime, including failures.
|
|
80
|
+
// Re-attaching a generation must not resurrect an old reference.
|
|
81
|
+
instance.value = undefined;
|
|
82
|
+
instance.disposers = [];
|
|
83
|
+
if (errors.length)
|
|
84
|
+
throw new AggregateError(errors, "Instance cleanup failed.");
|
|
85
|
+
})();
|
|
86
|
+
void instance.disposal.then(instance.resolve, instance.reject);
|
|
87
|
+
return instance.disposal;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
function implementationKey(ref) {
|
|
91
|
+
for (const value of [ref.id, ref.generation]) {
|
|
92
|
+
if (typeof value !== "string" || !value.trim() || value.includes("\0")) {
|
|
93
|
+
throw new Error("Implementation identity is invalid.");
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return JSON.stringify([ref.id, ref.generation]);
|
|
97
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { createDurableJobControl } from "../controller/jobControl.js";
|
|
2
|
+
import { JOB_RUNNER_IMPLEMENTATION } from "../job/durableJob.js";
|
|
3
|
+
import { InstanceHost } from "./instanceHost.js";
|
|
4
|
+
/** Called once by the existing Controller root. Does not open a Store, start
|
|
5
|
+
* another Controller, or provide arbitrary persistence to plugin code.
|
|
6
|
+
* T02 registers contributions on this Host and wraps this same Job control.
|
|
7
|
+
*/
|
|
8
|
+
export function createKernelPorts(store, runner) {
|
|
9
|
+
const host = new InstanceHost();
|
|
10
|
+
const runnerImplementation = host.attach(JOB_RUNNER_IMPLEMENTATION, runner);
|
|
11
|
+
const runnerHandle = host.acquire(runnerImplementation);
|
|
12
|
+
const jobImplementation = host.attach({ id: "yui:job-control", generation: "1" }, createDurableJobControl(store));
|
|
13
|
+
// The Controller is a long-lived consumer of this exact implementation.
|
|
14
|
+
const jobHandle = host.acquire(jobImplementation);
|
|
15
|
+
return {
|
|
16
|
+
host,
|
|
17
|
+
jobImplementation,
|
|
18
|
+
runnerImplementation,
|
|
19
|
+
runner: runnerHandle.value,
|
|
20
|
+
jobs: jobHandle.value,
|
|
21
|
+
close: async () => {
|
|
22
|
+
const drain = host.close();
|
|
23
|
+
await jobHandle.release();
|
|
24
|
+
await runnerHandle.release();
|
|
25
|
+
await drain;
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/** A read model over the one Job, never separately persisted.
|
|
30
|
+
* confirmed means the selected runner was observed, not that every action
|
|
31
|
+
* performed by an arbitrary command succeeded. Inspect original step receipts.
|
|
32
|
+
*/
|
|
33
|
+
export function inspectJobOperation(job) {
|
|
34
|
+
return {
|
|
35
|
+
operationRef: { taskId: job.taskId, jobId: job.id },
|
|
36
|
+
...job.operation,
|
|
37
|
+
state: job.status === "queued" ? "pending"
|
|
38
|
+
: job.status === "running" ? "running"
|
|
39
|
+
: job.status === "unknown-needs-attention" ? "unknown" : "finished",
|
|
40
|
+
outcome: job.result?.outcome,
|
|
41
|
+
result: job.result,
|
|
42
|
+
checkpoint: job.checkpoint
|
|
43
|
+
};
|
|
44
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export function validateOperationFacts(facts) {
|
|
2
|
+
for (const value of [
|
|
3
|
+
facts?.requestId, facts?.actorId, facts?.authorityRef, facts?.targetId, facts?.capability,
|
|
4
|
+
facts?.implementation?.id, facts?.implementation?.generation
|
|
5
|
+
]) {
|
|
6
|
+
if (typeof value !== "string" || !value.trim() || value.includes("\0")) {
|
|
7
|
+
throw new Error("Operation identity is invalid.");
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
if (!/^[a-f0-9]{64}$/u.test(facts.inputDigest))
|
|
11
|
+
throw new Error("Operation input digest is invalid.");
|
|
12
|
+
if (!["none", "possible", "confirmed"].includes(facts.effect))
|
|
13
|
+
throw new Error("Operation effect is invalid.");
|
|
14
|
+
for (const refs of [facts.receiptRefs, facts.partialResultRefs]) {
|
|
15
|
+
if (!Array.isArray(refs) || refs.some((ref) => typeof ref !== "string" || !ref.trim())) {
|
|
16
|
+
throw new Error("Operation evidence references are invalid.");
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/** Effects only accumulate. Later output failure or cancellation cannot erase evidence. */
|
|
21
|
+
export function recordOperationEvidence(facts, evidence) {
|
|
22
|
+
const rank = { none: 0, possible: 1, confirmed: 2 };
|
|
23
|
+
const next = {
|
|
24
|
+
...facts,
|
|
25
|
+
effect: evidence.effect !== undefined && rank[evidence.effect] > rank[facts.effect]
|
|
26
|
+
? evidence.effect : facts.effect,
|
|
27
|
+
receiptRefs: [...new Set([...facts.receiptRefs, ...(evidence.receiptRefs ?? [])])],
|
|
28
|
+
partialResultRefs: [...new Set([...facts.partialResultRefs, ...(evidence.partialResultRefs ?? [])])]
|
|
29
|
+
};
|
|
30
|
+
validateOperationFacts(next);
|
|
31
|
+
return next;
|
|
32
|
+
}
|