@zq-silk/yui 0.6.0 → 0.6.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 +5 -5
- package/dist/agent/managedRuntimeEnvironment.js +2 -1
- package/dist/cli/commandCatalog.js +251 -13
- package/dist/cli/updateOrchestrator.js +8 -0
- package/dist/cli/updatePorts.js +76 -22
- package/dist/cli.js +264 -20
- package/dist/commands/configCommands.js +83 -9
- package/dist/commands/controllerCommands.js +103 -0
- package/dist/commands/deliveryGuardPreflight.js +30 -0
- package/dist/commands/durableJobCommands.js +231 -0
- package/dist/commands/executionAuditCommands.js +193 -0
- package/dist/commands/grantCommands.js +374 -0
- package/dist/commands/projectCommands.js +119 -81
- package/dist/commands/releaseCommands.js +444 -0
- package/dist/commands/resourcesCommands.js +274 -0
- package/dist/commands/sessionCommands.js +104 -0
- package/dist/commands/taskActor.js +117 -0
- package/dist/commands/taskChangeSetCommands.js +60 -0
- package/dist/commands/taskCommands.js +618 -202
- package/dist/commands/taskCompletionGate.js +78 -1
- package/dist/commands/taskContextCommand.js +33 -6
- package/dist/commands/taskInputCommands.js +1 -1
- package/dist/commands/taskIntegrationCommands.js +136 -33
- package/dist/commands/taskIntegrationQueueCommands.js +228 -0
- package/dist/commands/taskNextActionCommand.js +100 -0
- package/dist/commands/taskOverlapCommands.js +120 -0
- package/dist/commands/taskOverviewCommand.js +36 -8
- package/dist/commands/telemetryCommands.js +330 -0
- package/dist/commands/workflowCommands.js +415 -0
- package/dist/config/yuiConfig.js +62 -0
- package/dist/controller/clientRuntime.js +42 -1
- package/dist/controller/controller.js +402 -56
- package/dist/controller/controllerMain.js +25 -2
- package/dist/controller/domainIdentity.js +16 -8
- package/dist/controller/fileSchedulerStoreAdapter.js +423 -31
- package/dist/controller/handoverCandidate.js +168 -0
- package/dist/controller/jobClient.js +102 -0
- package/dist/controller/jobControl.js +613 -0
- package/dist/controller/jobSupervisor.js +498 -0
- package/dist/controller/providerHookRunFence.js +34 -5
- package/dist/controller/resourceCleanupLinux.js +18 -9
- package/dist/controller/resourceInventoryLinux.js +90 -39
- package/dist/controller/runtime.js +165 -15
- package/dist/controller/runtimeEventInbox.js +234 -57
- package/dist/controller/runtimeEventProcessor.js +297 -58
- package/dist/controller/sessionOwnerReconciliation.js +321 -0
- package/dist/core/controllerServer.js +416 -27
- package/dist/core/controllerTelemetry.js +167 -0
- package/dist/doctor/doctor.js +113 -16
- package/dist/domain/validation.js +9 -0
- package/dist/execution/executionGroup.js +40 -3
- package/dist/executor/agentExecutor.js +6 -3
- package/dist/executor/effectiveLaunch.js +52 -0
- package/dist/executor/executorRegistry.js +50 -0
- package/dist/executor/fileRoleLaunchPlanner.js +61 -6
- package/dist/grant/capabilityGrant.js +282 -0
- package/dist/integration/changeSet.js +16 -3
- package/dist/integration/changeSetManifest.js +46 -0
- package/dist/integration/gitIntegrationService.js +528 -147
- package/dist/integration/integrationAttempt.js +54 -5
- package/dist/integration/integrationQueueEntry.js +221 -0
- package/dist/integration/integrationQueueService.js +955 -0
- package/dist/integration/manifestTags.js +99 -0
- package/dist/integration/overlapDiagnostics.js +211 -0
- package/dist/job/durableJob.js +449 -0
- package/dist/job/jobRunner.js +350 -0
- package/dist/lifecycle/exactRunTerminalization.js +24 -2
- package/dist/lifecycle/providerErrorClass.js +126 -0
- package/dist/message/message.js +16 -3
- package/dist/observability/executionAudit.js +545 -0
- package/dist/observability/faultClassification.js +160 -0
- package/dist/observability/runtimeIdentity.js +367 -0
- package/dist/release/fakeReleasePorts.js +55 -0
- package/dist/release/releaseHandover.js +475 -0
- package/dist/release/releaseIdempotencyStore.js +165 -0
- package/dist/release/releaseWorkflow.js +459 -0
- package/dist/release/releaseWorkflowEngine.js +688 -0
- package/dist/release/releaseWorkflowPorts.js +1720 -0
- package/dist/release/runtimeRelease.js +495 -0
- package/dist/release/workflowFileLock.js +218 -0
- package/dist/repository/gitWorkspace.js +177 -1
- package/dist/repository/projectMaintenanceLock.js +315 -0
- package/dist/repository/taskWorkspaceCoordinator.js +87 -17
- package/dist/repository/taskWorkspacePreparer.js +1091 -517
- package/dist/resources/autoResourceGc.js +116 -0
- package/dist/resources/liveReferences.js +574 -0
- package/dist/resources/resourceDiscovery.js +477 -0
- package/dist/resources/resourceGc.js +645 -0
- package/dist/resources/resourceRegistrar.js +256 -0
- package/dist/resources/resourceRegistry.js +150 -0
- package/dist/resources/resourceRegistryStore.js +41 -0
- package/dist/resources/resourceTypes.js +42 -0
- package/dist/resources/sqliteResourceRegistry.js +111 -0
- package/dist/review/reviewConfig.js +10 -0
- package/dist/review/reviewFinding.js +240 -0
- package/dist/review/reviewFindingLedger.js +545 -0
- package/dist/review/reviewOutcomeClassifier.js +61 -0
- package/dist/review/reviewRound.js +56 -4
- package/dist/run/agentRun.js +80 -4
- package/dist/run/providerRetry.js +84 -0
- package/dist/run/providerRetryConfig.js +63 -0
- package/dist/run/yieldReceipt.js +65 -0
- package/dist/runtime/exactControlPlane.js +79 -2
- package/dist/runtime/index.js +4 -0
- package/dist/runtime/sessionOwnerIdentity.js +269 -0
- package/dist/runtime/sessionOwnerRegistry.js +132 -0
- package/dist/runtime/sessionReconciliation.js +93 -0
- package/dist/runtime/sessionTerminationGuard.js +211 -0
- package/dist/runtime/taskRuntimeIsolation.js +13 -0
- package/dist/runtime/tmuxAdapters.js +34 -1
- package/dist/scheduler/actionability.js +155 -0
- package/dist/scheduler/activeRoleRunDelivery.js +14 -5
- package/dist/scheduler/activeTaskProgress.js +60 -0
- package/dist/scheduler/leaderWakeupProcessor.js +22 -11
- package/dist/scheduler/roleRunStall.js +135 -29
- package/dist/scheduler/taskExecutionProjection.js +11 -0
- package/dist/storage/compatibleTaskStore.js +112 -5
- package/dist/storage/migration/productionRegistry.js +736 -1
- package/dist/storage/sqliteSchema.js +264 -3
- package/dist/storage/sqliteStore.js +487 -13
- package/dist/storage/storeRpc.js +21 -0
- package/dist/storage/taskStore.js +974 -21
- package/dist/storage/upgrade/homeClassification.js +120 -2
- package/dist/storage/upgrade/migrationReceipt.js +67 -0
- package/dist/storage/upgrade/pseudoLayoutRepair.js +241 -0
- package/dist/storage/upgrade/recordVersions.js +10 -1
- package/dist/storage/upgrade/sqliteMigrationTarget.js +58 -6
- package/dist/storage/upgrade/sqliteRecordMigrationTarget.js +290 -0
- package/dist/storage/upgrade/sqliteStateMigration.js +258 -2
- package/dist/storage/upgrade/upgradeOrchestrator.js +482 -16
- package/dist/task/deliveryGuard.js +226 -0
- package/dist/task/nextAction.js +738 -0
- package/dist/task/repairWave.js +137 -0
- package/dist/task/taskRecordReference.js +6 -1
- package/dist/telemetry/sqliteTelemetryStore.js +387 -0
- package/dist/telemetry/telemetryCompaction.js +251 -0
- package/dist/telemetry/telemetryConfig.js +64 -0
- package/dist/telemetry/telemetryRouter.js +32 -0
- package/dist/telemetry/telemetryStore.js +19 -0
- package/dist/telemetry/telemetryWiring.js +33 -0
- package/dist/tmux/tmuxManager.js +20 -1
- package/dist/tmux/tmuxSocketEndpoint.js +20 -0
- package/dist/verification/gateArtifact.js +216 -0
- package/dist/verification/gateArtifactStore.js +87 -0
- package/dist/verification/verificationGateService.js +414 -0
- package/dist/verification/verificationPlan.js +308 -0
- package/dist/workspace/gitChangeSetCapture.js +12 -2
- package/dist/workspace/workItemChangeSetManager.js +60 -3
- package/package.json +1 -1
- package/skills/yui-leader/SKILL.md +8 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
export const DEFAULT_GRACEFUL_GRACE_MS = 3_000;
|
|
2
|
+
export const DEFAULT_FORCED_GRACE_MS = 2_000;
|
|
3
|
+
export const DEFAULT_TERMINATION_POLL_MS = 100;
|
|
4
|
+
/**
|
|
5
|
+
* Stops one Role owner with physical exit proof.
|
|
6
|
+
*
|
|
7
|
+
* Sequence: graceful exact-owner tmux stop -> bounded grace -> forced
|
|
8
|
+
* SIGTERM/SIGKILL of the exact Provider root and its process group -> final
|
|
9
|
+
* /proc re-verification. Only when every owned root is absent (PID gone or
|
|
10
|
+
* start identity changed) does the result become `stop-confirmed`; otherwise
|
|
11
|
+
* the Session stays non-terminal and the caller must not commit `stopped`.
|
|
12
|
+
*
|
|
13
|
+
* PID reuse is handled structurally: liveness always pairs the PID with its
|
|
14
|
+
* recorded start identity, so a recycled PID is treated as absent and never
|
|
15
|
+
* signaled.
|
|
16
|
+
*/
|
|
17
|
+
export async function terminateSessionOwners(owner, records, ports, options = {}) {
|
|
18
|
+
const gracefulGraceMs = positiveDuration(options.gracefulGraceMs, DEFAULT_GRACEFUL_GRACE_MS, "gracefulGraceMs");
|
|
19
|
+
const forcedGraceMs = positiveDuration(options.forcedGraceMs, DEFAULT_FORCED_GRACE_MS, "forcedGraceMs");
|
|
20
|
+
const pollMs = positiveDuration(options.pollMs, DEFAULT_TERMINATION_POLL_MS, "pollMs");
|
|
21
|
+
const now = ports.now();
|
|
22
|
+
ports.emit({ stage: "stop-requested", owner, at: now });
|
|
23
|
+
let gracefulOk = true;
|
|
24
|
+
try {
|
|
25
|
+
gracefulOk = await ports.gracefulStop(owner);
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
gracefulOk = false;
|
|
29
|
+
ports.emit({
|
|
30
|
+
stage: "graceful-stop",
|
|
31
|
+
owner,
|
|
32
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
33
|
+
at: ports.now()
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
if (gracefulOk) {
|
|
37
|
+
ports.emit({ stage: "graceful-stop", owner, at: ports.now() });
|
|
38
|
+
}
|
|
39
|
+
if (records.length === 0) {
|
|
40
|
+
// No physical owner record: tmux-level cleanup is the only proof
|
|
41
|
+
// available. Report the gap rather than claiming physical zero.
|
|
42
|
+
return {
|
|
43
|
+
outcome: gracefulOk ? "stop-confirmed" : "stop-blocked",
|
|
44
|
+
owner,
|
|
45
|
+
confirmed: [],
|
|
46
|
+
remaining: [],
|
|
47
|
+
...(gracefulOk
|
|
48
|
+
? {}
|
|
49
|
+
: { verificationGap: "no-owner-record: tmux stop unconfirmed" })
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
// Bounded graceful grace: poll the exact owned trees for exit.
|
|
53
|
+
if (await waitForTreesAbsent(records, ports, gracefulGraceMs, pollMs)) {
|
|
54
|
+
ports.emit({ stage: "stop-confirmed", owner, at: ports.now() });
|
|
55
|
+
return {
|
|
56
|
+
outcome: "stop-confirmed",
|
|
57
|
+
owner,
|
|
58
|
+
confirmed: records,
|
|
59
|
+
remaining: []
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
// Forced escalation against the exact roots only. A root whose identity
|
|
63
|
+
// cannot be verified (verification gap) is never signaled: PID reuse
|
|
64
|
+
// safety requires pairing the PID with its start identity.
|
|
65
|
+
ports.emit({ stage: "forced-stop", owner, at: ports.now() });
|
|
66
|
+
for (const record of records) {
|
|
67
|
+
escalateRecord(record, ports, "SIGTERM");
|
|
68
|
+
}
|
|
69
|
+
await waitForTreesAbsent(records, ports, forcedGraceMs, pollMs);
|
|
70
|
+
for (const record of records) {
|
|
71
|
+
escalateRecord(record, ports, "SIGKILL");
|
|
72
|
+
}
|
|
73
|
+
await waitForTreesAbsent(records, ports, forcedGraceMs, pollMs);
|
|
74
|
+
const remaining = [];
|
|
75
|
+
const confirmed = [];
|
|
76
|
+
let verificationGap;
|
|
77
|
+
for (const record of records) {
|
|
78
|
+
const verdict = treeVerdict(record, ports);
|
|
79
|
+
if (verdict.kind === "absent") {
|
|
80
|
+
confirmed.push(record);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (verdict.kind === "gap") {
|
|
84
|
+
verificationGap = `/proc unreadable for pid ${record.providerRoot.pid}`;
|
|
85
|
+
remaining.push({ record, detail: verificationGap });
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
const root = rootVerdict(record, ports);
|
|
89
|
+
remaining.push({
|
|
90
|
+
record,
|
|
91
|
+
detail: root.kind === "live"
|
|
92
|
+
? `provider root pid ${record.providerRoot.pid} still live`
|
|
93
|
+
: `provider children still live for launch ${record.launchId}`
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
const outcome = remaining.length === 0 ? "stop-confirmed" : "stop-blocked";
|
|
97
|
+
ports.emit({
|
|
98
|
+
stage: outcome === "stop-confirmed" ? "stop-confirmed" : "stop-blocked",
|
|
99
|
+
owner,
|
|
100
|
+
...(outcome === "stop-blocked"
|
|
101
|
+
? {
|
|
102
|
+
detail: remaining
|
|
103
|
+
.map(({ record, detail }) => `${record.launchId}: ${detail}`)
|
|
104
|
+
.join("; ")
|
|
105
|
+
}
|
|
106
|
+
: {}),
|
|
107
|
+
at: ports.now()
|
|
108
|
+
});
|
|
109
|
+
return {
|
|
110
|
+
outcome,
|
|
111
|
+
owner,
|
|
112
|
+
confirmed,
|
|
113
|
+
remaining,
|
|
114
|
+
...(verificationGap === undefined ? {} : { verificationGap })
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
function rootVerdict(record, ports) {
|
|
118
|
+
const { pid, startIdentity } = record.providerRoot;
|
|
119
|
+
const current = ports.processIdentity(pid);
|
|
120
|
+
if (current === undefined) {
|
|
121
|
+
// /proc unreadable or entry gone. Gone is absent; unreadable is a gap.
|
|
122
|
+
return ports.procEntryExists(pid) ? { kind: "gap" } : { kind: "absent" };
|
|
123
|
+
}
|
|
124
|
+
// A zombie has already exited; only the unreaped task struct remains. It
|
|
125
|
+
// cannot execute or hold resources, so it counts as absent for physical
|
|
126
|
+
// exit proof (the parent reaping the zombie does not change liveness).
|
|
127
|
+
if (current.state === "Z")
|
|
128
|
+
return { kind: "absent" };
|
|
129
|
+
return current.startIdentity === startIdentity
|
|
130
|
+
? { kind: "live" }
|
|
131
|
+
: { kind: "absent" };
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Verdict for the whole owned tree: the Provider root plus any surviving
|
|
135
|
+
* child carrying the exact launch fence. A verification gap counts as
|
|
136
|
+
* present: the grace polls must not short-circuit to `stop-confirmed` while
|
|
137
|
+
* `/proc` is unreadable, because that would claim physical zero without
|
|
138
|
+
* proof. The final verdict turns a persistent gap into `stop-blocked`.
|
|
139
|
+
*/
|
|
140
|
+
function treeVerdict(record, ports) {
|
|
141
|
+
const root = rootVerdict(record, ports);
|
|
142
|
+
if (root.kind === "live")
|
|
143
|
+
return { kind: "live" };
|
|
144
|
+
// The root is gone or unreadable: a surviving child carrying the exact
|
|
145
|
+
// launch fence keeps the tree live. Children without the fence are
|
|
146
|
+
// unattributed and never block confirmation on their own.
|
|
147
|
+
const fencedChildren = ports
|
|
148
|
+
.listLaunchFencedProcesses(record.launchId)
|
|
149
|
+
.filter((pid) => pid !== record.providerRoot.pid);
|
|
150
|
+
if (fencedChildren.length > 0)
|
|
151
|
+
return { kind: "live" };
|
|
152
|
+
return root;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Signals the exact root (only when its identity is verified) and, while the
|
|
156
|
+
* root is live, its process group. When the root has already exited but a
|
|
157
|
+
* fenced child survives, each fenced child is signaled individually: the
|
|
158
|
+
* group can no longer be proven ours once its leader is gone.
|
|
159
|
+
*/
|
|
160
|
+
function escalateRecord(record, ports, signal) {
|
|
161
|
+
const root = rootVerdict(record, ports);
|
|
162
|
+
if (root.kind === "live") {
|
|
163
|
+
try {
|
|
164
|
+
ports.signalProcess(record.providerRoot.pid, signal);
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
// The root may have exited between verdict and signal; the final
|
|
168
|
+
// verification is authoritative.
|
|
169
|
+
}
|
|
170
|
+
const { processGroupId } = record.providerRoot;
|
|
171
|
+
if (processGroupId !== undefined) {
|
|
172
|
+
try {
|
|
173
|
+
ports.signalProcessGroup(processGroupId, signal);
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
// Group may already be gone or the root may not be its leader.
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
if (root.kind !== "absent")
|
|
182
|
+
return;
|
|
183
|
+
for (const pid of ports.listLaunchFencedProcesses(record.launchId)) {
|
|
184
|
+
if (pid === record.providerRoot.pid)
|
|
185
|
+
continue;
|
|
186
|
+
try {
|
|
187
|
+
ports.signalProcess(pid, signal);
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
// A child may have exited between scan and signal.
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
async function waitForTreesAbsent(records, ports, timeoutMs, pollMs) {
|
|
195
|
+
const start = Date.now();
|
|
196
|
+
for (;;) {
|
|
197
|
+
if (records.every((record) => treeVerdict(record, ports).kind === "absent")) {
|
|
198
|
+
return true;
|
|
199
|
+
}
|
|
200
|
+
if (Date.now() - start >= timeoutMs)
|
|
201
|
+
return false;
|
|
202
|
+
await ports.sleep(Math.min(pollMs, Math.max(1, timeoutMs - (Date.now() - start))));
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
function positiveDuration(value, fallback, label) {
|
|
206
|
+
const resolved = value ?? fallback;
|
|
207
|
+
if (!Number.isSafeInteger(resolved) || resolved <= 0) {
|
|
208
|
+
throw new TypeError(`${label} must be a positive integer.`);
|
|
209
|
+
}
|
|
210
|
+
return resolved;
|
|
211
|
+
}
|
|
@@ -2,6 +2,7 @@ import { createHash, randomBytes } from "node:crypto";
|
|
|
2
2
|
import { lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, rmdirSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
4
4
|
import { validateManagedWorkspace } from "../worktree/managedWorkspace.js";
|
|
5
|
+
import { ResourceRegistrar } from "../resources/resourceRegistrar.js";
|
|
5
6
|
export const YUI_TASK_RUNTIME_ISOLATION_DESCRIPTOR = "YUI_TASK_RUNTIME_ISOLATION_DESCRIPTOR";
|
|
6
7
|
export const YUI_TASK_RUNTIME_SERVICE_NAMESPACE = "YUI_TASK_RUNTIME_SERVICE_NAMESPACE";
|
|
7
8
|
const MARKER_FILE = ".yui-task-runtime-owner.json";
|
|
@@ -15,12 +16,16 @@ export class FileTaskRuntimeIsolation {
|
|
|
15
16
|
#controlPlane;
|
|
16
17
|
#pathLayout;
|
|
17
18
|
#inspectResources;
|
|
19
|
+
#resourceRegistrarValue;
|
|
18
20
|
constructor(options) {
|
|
19
21
|
this.#runtimeRoot = canonicalPath(options.runtimeRoot, "Task runtime root");
|
|
20
22
|
this.#controlPlane = normalizeControlBoundary(options.controlPlane);
|
|
21
23
|
this.#pathLayout = taskRuntimePathLayout(options.pathLayout);
|
|
22
24
|
this.#inspectResources = options.inspectResources;
|
|
23
25
|
}
|
|
26
|
+
#resourceRegistrar() {
|
|
27
|
+
return this.#resourceRegistrarValue ??= new ResourceRegistrar(this.#controlPlane.yuiHome);
|
|
28
|
+
}
|
|
24
29
|
preflight(input) {
|
|
25
30
|
const descriptor = createTaskRuntimeIsolationDescriptor({
|
|
26
31
|
...input,
|
|
@@ -57,6 +62,7 @@ export class FileTaskRuntimeIsolation {
|
|
|
57
62
|
throw new Error(`Task runtime generation is not exactly owned: ${root}.`);
|
|
58
63
|
}
|
|
59
64
|
ensureOwnedDirectories(descriptor);
|
|
65
|
+
this.#resourceRegistrar().registerTaskRuntimeIsolation(descriptor);
|
|
60
66
|
return;
|
|
61
67
|
}
|
|
62
68
|
ensureDirectoryChain(this.#runtimeRoot, dirname(root));
|
|
@@ -73,6 +79,7 @@ export class FileTaskRuntimeIsolation {
|
|
|
73
79
|
mode: 0o600
|
|
74
80
|
});
|
|
75
81
|
ensureOwnedDirectories(descriptor);
|
|
82
|
+
this.#resourceRegistrar().registerTaskRuntimeIsolation(descriptor);
|
|
76
83
|
}
|
|
77
84
|
catch (error) {
|
|
78
85
|
const current = inspectGenerationRoot(descriptor, fingerprint);
|
|
@@ -113,6 +120,12 @@ export class FileTaskRuntimeIsolation {
|
|
|
113
120
|
throw new Error("Task runtime resources changed during cleanup claim.");
|
|
114
121
|
}
|
|
115
122
|
rmSync(claimed, { recursive: true });
|
|
123
|
+
this.#resourceRegistrar().markPathsDeleted([
|
|
124
|
+
descriptor.roots.generation,
|
|
125
|
+
descriptor.roots.data,
|
|
126
|
+
descriptor.roots.cache,
|
|
127
|
+
descriptor.roots.temporary
|
|
128
|
+
]);
|
|
116
129
|
}
|
|
117
130
|
catch (error) {
|
|
118
131
|
// Preserve a claimed-but-unverified resource. Restore its exact path only
|
|
@@ -11,12 +11,14 @@ export class TmuxSessionHost {
|
|
|
11
11
|
tmux;
|
|
12
12
|
#globalHostId;
|
|
13
13
|
#createBindingId;
|
|
14
|
+
#onHostCreated;
|
|
14
15
|
#launchTails = new Map();
|
|
15
16
|
constructor(planner, tmux, options = {}) {
|
|
16
17
|
this.planner = planner;
|
|
17
18
|
this.tmux = tmux;
|
|
18
19
|
this.#globalHostId = requireSafeIdentity(options.globalHostId ?? "operator", "Global tmux host id");
|
|
19
20
|
this.#createBindingId = options.createBindingId ?? randomUUID;
|
|
21
|
+
this.#onHostCreated = options.onHostCreated;
|
|
20
22
|
}
|
|
21
23
|
async start(request, beforeHostStart) {
|
|
22
24
|
return this.#launch(request, beforeHostStart);
|
|
@@ -190,7 +192,18 @@ export class TmuxSessionHost {
|
|
|
190
192
|
});
|
|
191
193
|
// Process creation is last: every local invariant has already passed.
|
|
192
194
|
const hostCreated = await ensureRoleWindow(this.tmux, hostId, planned.role, planned.launch);
|
|
193
|
-
|
|
195
|
+
if (hostCreated
|
|
196
|
+
&& request.owner.scope === "task"
|
|
197
|
+
&& planned.launch.env.YUI_JOB_CALLER_KEY !== undefined
|
|
198
|
+
&& this.planner.commitTaskCallerKey !== undefined) {
|
|
199
|
+
this.planner.commitTaskCallerKey({
|
|
200
|
+
taskId: request.owner.taskId,
|
|
201
|
+
roleName: request.owner.roleName,
|
|
202
|
+
agentId: request.agentId,
|
|
203
|
+
callerKey: planned.launch.env.YUI_JOB_CALLER_KEY
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
const binding = createRuntimeBinding({
|
|
194
207
|
id: bindingId,
|
|
195
208
|
launchId: request.launchId,
|
|
196
209
|
owner: request.owner,
|
|
@@ -207,6 +220,13 @@ export class TmuxSessionHost {
|
|
|
207
220
|
: {}),
|
|
208
221
|
...(nativeSessionId === undefined ? {} : { nativeSessionId })
|
|
209
222
|
});
|
|
223
|
+
if (hostCreated && this.#onHostCreated !== undefined) {
|
|
224
|
+
const pane = await inspectRolePane(this.tmux, hostId, request.owner.roleName);
|
|
225
|
+
if (pane !== undefined) {
|
|
226
|
+
this.#onHostCreated({ binding, pane });
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return binding;
|
|
210
230
|
}
|
|
211
231
|
}
|
|
212
232
|
/** Non-blocking receipt-backed tmux push; it never interprets provider output. */
|
|
@@ -238,6 +258,19 @@ async function probeRoleStatus(tmux, hostId, roleName) {
|
|
|
238
258
|
? tmux.probeRoleStatus(hostId, roleName)
|
|
239
259
|
: tmux.probeRoleStatusAsync(hostId, roleName);
|
|
240
260
|
}
|
|
261
|
+
async function inspectRolePane(tmux, hostId, roleName) {
|
|
262
|
+
try {
|
|
263
|
+
return tmux.inspectRolePaneAsync === undefined
|
|
264
|
+
? tmux.inspectRolePane?.(hostId, roleName)
|
|
265
|
+
: await tmux.inspectRolePaneAsync(hostId, roleName);
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
// The pane may have exited between creation and inspection; the owner
|
|
269
|
+
// recorder treats a missing pane as a verification gap, not a launch
|
|
270
|
+
// failure.
|
|
271
|
+
return undefined;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
241
274
|
async function killRole(tmux, hostId, roleName) {
|
|
242
275
|
if (tmux.killRoleAsync === undefined) {
|
|
243
276
|
tmux.killRole(hostId, roleName);
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { isDurableJobTerminal } from "../job/durableJob.js";
|
|
3
|
+
/**
|
|
4
|
+
* Canonical SHA-256 digest over the normalized actionable facts. Pure and
|
|
5
|
+
* deterministic: the same facts always produce the same digest regardless of
|
|
6
|
+
* collection order.
|
|
7
|
+
*/
|
|
8
|
+
export function computeActionabilityDigest(input) {
|
|
9
|
+
const facts = [...input.facts]
|
|
10
|
+
.map((fact) => ({ key: fact.key, value: fact.value }))
|
|
11
|
+
.sort((left, right) => (left.key < right.key ? -1 : left.key > right.key ? 1 : 0));
|
|
12
|
+
const canonical = JSON.stringify({
|
|
13
|
+
taskId: input.taskId,
|
|
14
|
+
taskStatus: input.taskStatus,
|
|
15
|
+
facts
|
|
16
|
+
});
|
|
17
|
+
return createHash("sha256").update(canonical).digest("hex");
|
|
18
|
+
}
|
|
19
|
+
const TERMINAL_WORK_ITEM_STATUSES = new Set(["completed", "retired"]);
|
|
20
|
+
const CONSUMED_INTEGRATION_STATUSES = new Set(["committed", "superseded"]);
|
|
21
|
+
/**
|
|
22
|
+
* Fold a Task's durable records into the normalized actionable facts. This
|
|
23
|
+
* function only reads; it never starts a Controller, queues a wake, writes a
|
|
24
|
+
* Message, or mutates any record.
|
|
25
|
+
*
|
|
26
|
+
* Included facts (each changes only when the durable record changes):
|
|
27
|
+
* - Active Runs: the ownership/liveness picture. A Run starting or ending
|
|
28
|
+
* changes the digest so a lost executor is detected.
|
|
29
|
+
* - Non-terminal/failed WorkItems: pending, running, awaiting-acceptance, and
|
|
30
|
+
* failed items still need Leader or Worker action.
|
|
31
|
+
* - Non-completed ReviewRounds: pending/running/failed rounds still need
|
|
32
|
+
* Leader disposition or a Reviewer.
|
|
33
|
+
* - Unresolved IntegrationAttempts: running/validating/blocked/failed attempts
|
|
34
|
+
* the Leader may need to resolve.
|
|
35
|
+
* - Terminal DurableJobs: new terminal results the Leader should observe.
|
|
36
|
+
* - InputRequests: open and answered/cancelled requests.
|
|
37
|
+
* - User directives explicitly marked `wakePolicy=leader`.
|
|
38
|
+
*
|
|
39
|
+
* Excluded: pure revision growth, progress/heartbeat events, repeated scans of
|
|
40
|
+
* an unchanged blocker, read-only status requests, and waits already owned by
|
|
41
|
+
* an active Worker/Reviewer/Job (the active-Run facts above capture ownership).
|
|
42
|
+
*/
|
|
43
|
+
export function collectTaskActionability(store, taskId) {
|
|
44
|
+
const task = store.getTask(taskId);
|
|
45
|
+
if (task === null) {
|
|
46
|
+
throw new Error(`Task not found for actionability projection: ${taskId}.`);
|
|
47
|
+
}
|
|
48
|
+
const facts = [];
|
|
49
|
+
for (const run of store.listAgentRuns(taskId).filter((candidate) => candidate.status === "active")) {
|
|
50
|
+
facts.push({
|
|
51
|
+
key: `active-run:${run.id}`,
|
|
52
|
+
value: [
|
|
53
|
+
run.roleName,
|
|
54
|
+
run.workItemId ?? "",
|
|
55
|
+
run.reviewRoundId ?? "",
|
|
56
|
+
run.updatedAt
|
|
57
|
+
].join("|")
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
for (const item of store.listWorkItems?.(taskId) ?? []) {
|
|
61
|
+
if (TERMINAL_WORK_ITEM_STATUSES.has(item.status))
|
|
62
|
+
continue;
|
|
63
|
+
facts.push({
|
|
64
|
+
key: `work-item:${item.id}`,
|
|
65
|
+
value: `${item.status}|${item.updatedAt}`
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
for (const round of store.listReviewRounds?.(taskId) ?? []) {
|
|
69
|
+
if (round.status === "completed")
|
|
70
|
+
continue;
|
|
71
|
+
facts.push({
|
|
72
|
+
key: `review:${round.id}`,
|
|
73
|
+
value: `${round.status}|${round.endedAt ?? round.createdAt}`
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
for (const attempt of store.listIntegrationAttempts?.(taskId) ?? []) {
|
|
77
|
+
if (CONSUMED_INTEGRATION_STATUSES.has(attempt.status))
|
|
78
|
+
continue;
|
|
79
|
+
facts.push({
|
|
80
|
+
key: `integration:${attempt.id}`,
|
|
81
|
+
value: `${attempt.status}|${attempt.updatedAt}`
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
for (const job of store.listDurableJobs?.(taskId) ?? []) {
|
|
85
|
+
if (!isDurableJobTerminal(job.status))
|
|
86
|
+
continue;
|
|
87
|
+
facts.push({
|
|
88
|
+
key: `durable-job:${job.id}`,
|
|
89
|
+
value: `${job.status}|${job.terminalAt ?? job.updatedAt}`
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
for (const request of store.listInputRequests?.(taskId) ?? []) {
|
|
93
|
+
facts.push({
|
|
94
|
+
key: `input:${request.id}`,
|
|
95
|
+
value: `${request.status}|${request.updatedAt}`
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
for (const message of store.listMessages?.(taskId) ?? []) {
|
|
99
|
+
if (message.wakePolicy !== "leader")
|
|
100
|
+
continue;
|
|
101
|
+
facts.push({
|
|
102
|
+
key: `directive:${message.id}`,
|
|
103
|
+
value: message.createdAt
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
return { taskId, taskStatus: task.status, facts };
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Derive the Leader Run disposition from the Task execution projection status.
|
|
110
|
+
* This is machine-derived at yield time so the Scheduler does not depend on
|
|
111
|
+
* free-text Leader summaries.
|
|
112
|
+
*/
|
|
113
|
+
export function deriveLeaderRunDisposition(projectionStatus, taskStatus) {
|
|
114
|
+
if (taskStatus !== "active")
|
|
115
|
+
return "completed";
|
|
116
|
+
switch (projectionStatus) {
|
|
117
|
+
case "blocked":
|
|
118
|
+
return "blocked";
|
|
119
|
+
case "waiting-on-agents":
|
|
120
|
+
case "waiting-user":
|
|
121
|
+
return "waiting";
|
|
122
|
+
case "completed":
|
|
123
|
+
case "retired":
|
|
124
|
+
case "archived":
|
|
125
|
+
return "completed";
|
|
126
|
+
default:
|
|
127
|
+
return "progress";
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Decide whether a `task-orphaned` scan should create a new Leader Run.
|
|
132
|
+
*
|
|
133
|
+
* Suppression applies only when the last Leader Run ended `waiting` or
|
|
134
|
+
* `blocked` and its `observedActionabilityDigest` equals the current digest.
|
|
135
|
+
* A `progress`/`completed` disposition, a missing digest, or a changed digest
|
|
136
|
+
* always wakes. Computation errors fail open.
|
|
137
|
+
*/
|
|
138
|
+
export function decideOrphanWake(input) {
|
|
139
|
+
const { currentDigest, lastLeaderRun } = input;
|
|
140
|
+
if (lastLeaderRun === null)
|
|
141
|
+
return { kind: "wake", digest: currentDigest };
|
|
142
|
+
if (lastLeaderRun.status === "active")
|
|
143
|
+
return { kind: "wake", digest: currentDigest };
|
|
144
|
+
const disposition = lastLeaderRun.disposition;
|
|
145
|
+
if (disposition !== "waiting" && disposition !== "blocked") {
|
|
146
|
+
return { kind: "wake", digest: currentDigest };
|
|
147
|
+
}
|
|
148
|
+
const observed = lastLeaderRun.observedActionabilityDigest;
|
|
149
|
+
if (observed === undefined)
|
|
150
|
+
return { kind: "wake", digest: currentDigest };
|
|
151
|
+
if (observed === currentDigest) {
|
|
152
|
+
return { kind: "suppress", digest: currentDigest, observedDigest: observed };
|
|
153
|
+
}
|
|
154
|
+
return { kind: "wake", digest: currentDigest };
|
|
155
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { selectedSchedulerRoles, selectedSchedulerTasks } from "./ports.js";
|
|
2
2
|
import { isSchedulerTaskWorkspaceReady } from "./ports.js";
|
|
3
3
|
import { formatAgentRunReceiptId } from "../task/taskRecordReference.js";
|
|
4
|
-
import { effectiveLaunchSnapshotsCompatible } from "../executor/effectiveLaunch.js";
|
|
4
|
+
import { effectiveLaunchSnapshotsCompatible, effectiveLaunchSnapshotsCompatibleForTaskMain } from "../executor/effectiveLaunch.js";
|
|
5
5
|
import { RuntimeLaunchError } from "../runtime/ports.js";
|
|
6
6
|
import { isPreInputReadinessSupported } from "../lifecycle/canonicalLifecycleEvent.js";
|
|
7
7
|
import { preInputReadinessCapability } from "../lifecycle/providerLifecycleMapping.js";
|
|
@@ -328,7 +328,7 @@ function requireResumeSession(role, effective, session) {
|
|
|
328
328
|
if (session === null || !hasText(session.nativeSessionId)) {
|
|
329
329
|
throw new Error(`Role resume has no fixed native session: ${role.taskId}/${role.name}.`);
|
|
330
330
|
}
|
|
331
|
-
if (!
|
|
331
|
+
if (!effectiveLaunchSnapshotsCompatibleForTaskMain(session.effective, effective, role.managedWorkspace)) {
|
|
332
332
|
throw new Error(`Role resume effective snapshot drifted: ${role.taskId}/${role.name}.`);
|
|
333
333
|
}
|
|
334
334
|
return session.nativeSessionId;
|
|
@@ -376,18 +376,27 @@ function validateRoleSession(role, effective, existing, mode, session) {
|
|
|
376
376
|
if (session.agentId !== effective.agentId || session.adapterId !== effective.adapterId) {
|
|
377
377
|
throw new Error(`Ready Role session identity changed: ${role.taskId}/${role.name}.`);
|
|
378
378
|
}
|
|
379
|
-
|
|
379
|
+
const compatible = mode === "resume"
|
|
380
|
+
? effectiveLaunchSnapshotsCompatibleForTaskMain(session.effective, effective, role.managedWorkspace)
|
|
381
|
+
: effectiveLaunchSnapshotsCompatible(session.effective, effective);
|
|
382
|
+
if (!compatible) {
|
|
380
383
|
throw new Error(`Ready Role session effective snapshot changed: ${role.taskId}/${role.name}.`);
|
|
381
384
|
}
|
|
382
385
|
if (existing?.nativeSessionId !== undefined
|
|
383
|
-
&&
|
|
386
|
+
&& effectiveLaunchSnapshotsCompatibleForTaskMain(existing.effective, effective, role.managedWorkspace)
|
|
384
387
|
&& session.nativeSessionId !== existing.nativeSessionId) {
|
|
385
388
|
throw new Error(`Ready Role session changed the fixed native session id: ${role.taskId}/${role.name}.`);
|
|
386
389
|
}
|
|
387
390
|
if (mode === "resume" && session.nativeSessionId !== existing?.nativeSessionId) {
|
|
388
391
|
throw new Error(`Role resume changed the fixed native session id: ${role.taskId}/${role.name}.`);
|
|
389
392
|
}
|
|
390
|
-
return {
|
|
393
|
+
return {
|
|
394
|
+
...session,
|
|
395
|
+
status: "running",
|
|
396
|
+
...(mode === "resume" && existing !== null
|
|
397
|
+
? { effective: existing.effective }
|
|
398
|
+
: {})
|
|
399
|
+
};
|
|
391
400
|
}
|
|
392
401
|
function hasText(value) {
|
|
393
402
|
return typeof value === "string" && value.trim().length > 0;
|
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
import { selectedSchedulerTasks, isSchedulerTaskWorkspaceReady } from "./ports.js";
|
|
2
2
|
import { queueLeaderWakeup } from "./wakeupQueue.js";
|
|
3
3
|
import { projectTaskExecution } from "./taskExecutionProjection.js";
|
|
4
|
+
import { collectTaskActionability, computeActionabilityDigest, decideOrphanWake } from "./actionability.js";
|
|
4
5
|
/**
|
|
5
6
|
* Repairs an active Task that has no durable owner capable of advancing it.
|
|
6
7
|
* This is a low-frequency safety net; normal transitions enqueue their own
|
|
7
8
|
* Role mailbox signal in the same transaction.
|
|
9
|
+
*
|
|
10
|
+
* Issue 05: a `task-orphaned` wake is suppressed when the last Leader Run
|
|
11
|
+
* ended waiting/blocked and its observed actionability digest equals the
|
|
12
|
+
* current digest. Suppression is silent — no Message, Event, or progress
|
|
13
|
+
* record is written — because a periodic scan that changes nothing is Yui
|
|
14
|
+
* engineering behavior with no Task-visible effect. Computation errors fail
|
|
15
|
+
* open: the Task is woken once and the error is recorded so it can never be
|
|
16
|
+
* silently starved.
|
|
8
17
|
*/
|
|
9
18
|
export function repairOrphanedActiveTasks(store, now, selection) {
|
|
10
19
|
const repaired = [];
|
|
@@ -36,6 +45,13 @@ export function repairOrphanedActiveTasks(store, now, selection) {
|
|
|
36
45
|
|| hasUnclaimedLeaderWork(store, task.id, leaderMailbox)) {
|
|
37
46
|
continue;
|
|
38
47
|
}
|
|
48
|
+
// Issue 05: digest-based admission. Only the "no-executor" orphan path
|
|
49
|
+
// reaches here; every other needs-leader-action state already has a
|
|
50
|
+
// durable owner (candidate, integration, or pending wake) and is exempt.
|
|
51
|
+
if (projection.reason === "no-executor") {
|
|
52
|
+
if (admitOrphanWake(store, task.id) === "suppress")
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
39
55
|
const taskWorkspace = store.getTaskWorkspace(task.id);
|
|
40
56
|
if (!isSchedulerTaskWorkspaceReady(task, taskWorkspace)) {
|
|
41
57
|
store.queueTaskProgress(task.id, "task-orphaned", now);
|
|
@@ -55,6 +71,50 @@ export function repairOrphanedActiveTasks(store, now, selection) {
|
|
|
55
71
|
}
|
|
56
72
|
return repaired;
|
|
57
73
|
}
|
|
74
|
+
/**
|
|
75
|
+
* Decide whether one `task-orphaned` scan should wake the Leader. Returns
|
|
76
|
+
* `"suppress"` without writing anything when the digest is unchanged since
|
|
77
|
+
* the last waiting/blocked Leader Run. Computation errors fail open.
|
|
78
|
+
*/
|
|
79
|
+
function admitOrphanWake(store, taskId) {
|
|
80
|
+
let digest;
|
|
81
|
+
try {
|
|
82
|
+
const input = collectTaskActionability(store, taskId);
|
|
83
|
+
digest = computeActionabilityDigest(input);
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
// Fail open: wake once and record the computation error so the Task can
|
|
87
|
+
// never be silently starved by a broken projection.
|
|
88
|
+
store.queueTaskProgress(taskId, "actionability-unknown", new Date());
|
|
89
|
+
return "wake";
|
|
90
|
+
}
|
|
91
|
+
const lastLeaderRun = findLastLeaderRun(store, taskId);
|
|
92
|
+
const decision = decideOrphanWake({ currentDigest: digest, lastLeaderRun });
|
|
93
|
+
return decision.kind === "suppress" ? "suppress" : "wake";
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* The most recent Leader Run (terminal or active). Active Runs are returned
|
|
97
|
+
* so the admission check never suppresses while a Leader is still running.
|
|
98
|
+
*/
|
|
99
|
+
function findLastLeaderRun(store, taskId) {
|
|
100
|
+
const runs = store.listAgentRuns?.(taskId) ?? [];
|
|
101
|
+
let latest = null;
|
|
102
|
+
for (const run of runs) {
|
|
103
|
+
if (run.roleName !== "leader")
|
|
104
|
+
continue;
|
|
105
|
+
if (latest === null || run.createdAt > latest.createdAt)
|
|
106
|
+
latest = run;
|
|
107
|
+
}
|
|
108
|
+
if (latest === null)
|
|
109
|
+
return null;
|
|
110
|
+
return {
|
|
111
|
+
status: latest.status,
|
|
112
|
+
...(latest.disposition === undefined ? {} : { disposition: latest.disposition }),
|
|
113
|
+
...(latest.observedActionabilityDigest === undefined
|
|
114
|
+
? {}
|
|
115
|
+
: { observedActionabilityDigest: latest.observedActionabilityDigest })
|
|
116
|
+
};
|
|
117
|
+
}
|
|
58
118
|
function hasUnclaimedLeaderWork(store, taskId, leaderMailbox) {
|
|
59
119
|
if (leaderMailbox === null)
|
|
60
120
|
return store.getPendingWakeup(taskId) !== null;
|