@zq-silk/yui 0.6.2 → 0.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/ARCHITECTURE.md +28 -4
  2. package/README.md +60 -97
  3. package/dist/agent/argumentPolicy.js +1 -1
  4. package/dist/agent/managedRuntimeEnvironment.js +1 -0
  5. package/dist/cli/commandCatalog.js +19 -9
  6. package/dist/cli/interactionPolicy.js +4 -2
  7. package/dist/cli.js +77 -32
  8. package/dist/commands/taskCommands.js +46 -11
  9. package/dist/commands/taskContextCommand.js +1 -1
  10. package/dist/commands/taskRoleRuntimeStatus.js +170 -10
  11. package/dist/controller/agentRuntimeObserver.js +210 -0
  12. package/dist/controller/clientRuntime.js +3 -21
  13. package/dist/controller/controller.js +47 -7
  14. package/dist/controller/fileSchedulerStoreAdapter.js +522 -388
  15. package/dist/controller/runtime.js +9 -3
  16. package/dist/controller/runtimeEventInbox.js +49 -295
  17. package/dist/controller/runtimeEventProcessor.js +184 -321
  18. package/dist/controller/runtimeHookRunFence.js +226 -0
  19. package/dist/controller/runtimeLaunchCoordinator.js +91 -26
  20. package/dist/controller/runtimeObservationHook.js +112 -0
  21. package/dist/core/controllerServer.js +5 -0
  22. package/dist/executor/agentAdapter.js +18 -3
  23. package/dist/executor/fileRoleLaunchPlanner.js +64 -15
  24. package/dist/executor/managedClaudeRunner.js +121 -0
  25. package/dist/observability/executionAudit.js +6 -3
  26. package/dist/repository/taskWorkspacePreparer.js +1 -4
  27. package/dist/run/providerRetryConfig.js +8 -3
  28. package/dist/runtime/agentDriver.js +229 -0
  29. package/dist/runtime/agentDriverObservation.js +57 -0
  30. package/dist/runtime/builtinAgentDrivers.js +235 -0
  31. package/dist/runtime/builtinTranscriptObserver.js +290 -0
  32. package/dist/runtime/builtinTranscriptUsage.js +97 -0
  33. package/dist/runtime/exactControlPlane.js +2 -2
  34. package/dist/runtime/index.js +1 -1
  35. package/dist/runtime/ports.js +12 -1
  36. package/dist/runtime/runtimeObservation.js +297 -0
  37. package/dist/runtime/runtimeProjection.js +277 -0
  38. package/dist/runtime/sessionTerminationGuard.js +78 -22
  39. package/dist/runtime/tmuxAdapters.js +35 -0
  40. package/dist/scheduler/activeRoleRunDelivery.js +28 -13
  41. package/dist/scheduler/leaderWakeupProcessor.js +21 -2
  42. package/dist/scheduler/roleRunLiveness.js +2 -2
  43. package/dist/scheduler/roleRunStall.js +62 -114
  44. package/dist/storage/migration/productionRegistry.js +85 -0
  45. package/dist/storage/sqliteStore.js +3 -3
  46. package/dist/storage/storageVersions.js +1 -1
  47. package/dist/storage/upgrade/sqliteRecordMigrationTarget.js +2 -1
  48. package/dist/storage/upgrade/sqliteStateMigration.js +123 -0
  49. package/dist/telemetry/sqliteTelemetryStore.js +0 -28
  50. package/dist/telemetry/telemetryCompaction.js +1 -0
  51. package/dist/telemetry/telemetryConfig.js +4 -5
  52. package/dist/tmux/tmuxManager.js +136 -22
  53. package/dist/web/assets/client/view.js +1 -1
  54. package/dist/web/tmuxWebTerminal.js +17 -12
  55. package/dist/web/webSnapshot.js +1 -1
  56. package/dist/worktree/managedWorkspace.js +14 -0
  57. package/i18n/README.zh-CN.md +12 -7
  58. package/package.json +1 -1
  59. package/dist/controller/claudeLifecycleHook.js +0 -203
  60. package/dist/controller/codexLifecycleHook.js +0 -108
  61. package/dist/controller/providerHookRunFence.js +0 -156
  62. package/dist/lifecycle/providerLifecycleMapping.js +0 -190
  63. package/dist/telemetry/telemetryRouter.js +0 -32
@@ -20,6 +20,14 @@ export async function terminateSessionOwners(owner, records, ports, options = {}
20
20
  const pollMs = positiveDuration(options.pollMs, DEFAULT_TERMINATION_POLL_MS, "pollMs");
21
21
  const now = ports.now();
22
22
  ports.emit({ stage: "stop-requested", owner, at: now });
23
+ // A launch-fence scan is a point-in-time observation, not a durable owner
24
+ // inventory: /proc entries can disappear between the directory and
25
+ // environment reads. Retain every exact child identity observed during the
26
+ // stop so one later scan miss cannot be mistaken for physical zero.
27
+ const trackedChildren = new Map(records.map((record) => [record.launchId, new Map()]));
28
+ for (const record of records) {
29
+ refreshTrackedFencedChildren(record, ports, trackedChildren);
30
+ }
23
31
  let gracefulOk = true;
24
32
  try {
25
33
  gracefulOk = await ports.gracefulStop(owner);
@@ -50,7 +58,7 @@ export async function terminateSessionOwners(owner, records, ports, options = {}
50
58
  };
51
59
  }
52
60
  // Bounded graceful grace: poll the exact owned trees for exit.
53
- if (await waitForTreesAbsent(records, ports, gracefulGraceMs, pollMs)) {
61
+ if (await waitForTreesAbsent(records, ports, trackedChildren, gracefulGraceMs, pollMs)) {
54
62
  ports.emit({ stage: "stop-confirmed", owner, at: ports.now() });
55
63
  return {
56
64
  outcome: "stop-confirmed",
@@ -64,24 +72,24 @@ export async function terminateSessionOwners(owner, records, ports, options = {}
64
72
  // safety requires pairing the PID with its start identity.
65
73
  ports.emit({ stage: "forced-stop", owner, at: ports.now() });
66
74
  for (const record of records) {
67
- escalateRecord(record, ports, "SIGTERM");
75
+ escalateRecord(record, ports, trackedChildren, "SIGTERM");
68
76
  }
69
- await waitForTreesAbsent(records, ports, forcedGraceMs, pollMs);
77
+ await waitForTreesAbsent(records, ports, trackedChildren, forcedGraceMs, pollMs);
70
78
  for (const record of records) {
71
- escalateRecord(record, ports, "SIGKILL");
79
+ escalateRecord(record, ports, trackedChildren, "SIGKILL");
72
80
  }
73
- await waitForTreesAbsent(records, ports, forcedGraceMs, pollMs);
81
+ await waitForTreesAbsent(records, ports, trackedChildren, forcedGraceMs, pollMs);
74
82
  const remaining = [];
75
83
  const confirmed = [];
76
84
  let verificationGap;
77
85
  for (const record of records) {
78
- const verdict = treeVerdict(record, ports);
86
+ const verdict = treeVerdict(record, ports, trackedChildren);
79
87
  if (verdict.kind === "absent") {
80
88
  confirmed.push(record);
81
89
  continue;
82
90
  }
83
91
  if (verdict.kind === "gap") {
84
- verificationGap = `/proc unreadable for pid ${record.providerRoot.pid}`;
92
+ verificationGap = `/proc unreadable for launch ${record.launchId}`;
85
93
  remaining.push({ record, detail: verificationGap });
86
94
  continue;
87
95
  }
@@ -137,19 +145,59 @@ function rootVerdict(record, ports) {
137
145
  * `/proc` is unreadable, because that would claim physical zero without
138
146
  * proof. The final verdict turns a persistent gap into `stop-blocked`.
139
147
  */
140
- function treeVerdict(record, ports) {
148
+ function treeVerdict(record, ports, trackedChildren) {
149
+ const scanGap = refreshTrackedFencedChildren(record, ports, trackedChildren);
141
150
  const root = rootVerdict(record, ports);
142
- if (root.kind === "live")
151
+ const children = trackedChildrenVerdict(record, ports, trackedChildren);
152
+ if (root.kind === "live" || children.kind === "live")
143
153
  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)
154
+ if (root.kind === "gap" || children.kind === "gap" || scanGap)
155
+ return { kind: "gap" };
156
+ return { kind: "absent" };
157
+ }
158
+ function refreshTrackedFencedChildren(record, ports, trackedChildren) {
159
+ const tracked = trackedChildren.get(record.launchId);
160
+ if (tracked === undefined)
161
+ return true;
162
+ let verificationGap = false;
163
+ for (const pid of ports.listLaunchFencedProcesses(record.launchId)) {
164
+ if (pid === record.providerRoot.pid)
165
+ continue;
166
+ const identity = ports.processIdentity(pid);
167
+ if (identity === undefined) {
168
+ if (ports.procEntryExists(pid))
169
+ verificationGap = true;
170
+ continue;
171
+ }
172
+ if (identity.state === "Z")
173
+ continue;
174
+ tracked.set(pid, identity.startIdentity);
175
+ }
176
+ return verificationGap;
177
+ }
178
+ function trackedChildrenVerdict(record, ports, trackedChildren) {
179
+ const tracked = trackedChildren.get(record.launchId);
180
+ if (tracked === undefined)
181
+ return { kind: "gap" };
182
+ let verificationGap = false;
183
+ for (const [pid, startIdentity] of tracked) {
184
+ const current = ports.processIdentity(pid);
185
+ if (current === undefined) {
186
+ if (ports.procEntryExists(pid)) {
187
+ verificationGap = true;
188
+ }
189
+ else {
190
+ tracked.delete(pid);
191
+ }
192
+ continue;
193
+ }
194
+ if (current.state === "Z" || current.startIdentity !== startIdentity) {
195
+ tracked.delete(pid);
196
+ continue;
197
+ }
151
198
  return { kind: "live" };
152
- return root;
199
+ }
200
+ return verificationGap ? { kind: "gap" } : { kind: "absent" };
153
201
  }
154
202
  /**
155
203
  * Signals the exact root (only when its identity is verified) and, while the
@@ -157,7 +205,7 @@ function treeVerdict(record, ports) {
157
205
  * fenced child survives, each fenced child is signaled individually: the
158
206
  * group can no longer be proven ours once its leader is gone.
159
207
  */
160
- function escalateRecord(record, ports, signal) {
208
+ function escalateRecord(record, ports, trackedChildren, signal) {
161
209
  const root = rootVerdict(record, ports);
162
210
  if (root.kind === "live") {
163
211
  try {
@@ -180,9 +228,17 @@ function escalateRecord(record, ports, signal) {
180
228
  }
181
229
  if (root.kind !== "absent")
182
230
  return;
183
- for (const pid of ports.listLaunchFencedProcesses(record.launchId)) {
184
- if (pid === record.providerRoot.pid)
231
+ refreshTrackedFencedChildren(record, ports, trackedChildren);
232
+ const tracked = trackedChildren.get(record.launchId);
233
+ if (tracked === undefined)
234
+ return;
235
+ for (const [pid, startIdentity] of tracked) {
236
+ const current = ports.processIdentity(pid);
237
+ if (current === undefined
238
+ || current.state === "Z"
239
+ || current.startIdentity !== startIdentity) {
185
240
  continue;
241
+ }
186
242
  try {
187
243
  ports.signalProcess(pid, signal);
188
244
  }
@@ -191,10 +247,10 @@ function escalateRecord(record, ports, signal) {
191
247
  }
192
248
  }
193
249
  }
194
- async function waitForTreesAbsent(records, ports, timeoutMs, pollMs) {
250
+ async function waitForTreesAbsent(records, ports, trackedChildren, timeoutMs, pollMs) {
195
251
  const start = Date.now();
196
252
  for (;;) {
197
- if (records.every((record) => treeVerdict(record, ports).kind === "absent")) {
253
+ if (records.every((record) => treeVerdict(record, ports, trackedChildren).kind === "absent")) {
198
254
  return true;
199
255
  }
200
256
  if (Date.now() - start >= timeoutMs)
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { createRuntimeBinding } from "./runtimeBinding.js";
3
3
  import { normalizeRuntimeOwner } from "./runtimeOwner.js";
4
+ import { RuntimeHostContentionError } from "./ports.js";
4
5
  import { requireSafeIdentity } from "./validation.js";
5
6
  /**
6
7
  * Runtime lifecycle adapter for the current tmux host. The returned hostRef is
@@ -145,6 +146,40 @@ export class TmuxSessionHost {
145
146
  async #launchUnlocked(request, hostId, beforeHostStart) {
146
147
  // Validate generated identity before starting an external process.
147
148
  const bindingId = requireSafeIdentity(this.#createBindingId(), "Runtime binding id");
149
+ const writableHumanAttached = request.owner.scope === "task"
150
+ && request.runId !== undefined
151
+ && (this.tmux.hasWritableClientAsync !== undefined
152
+ ? await this.tmux.hasWritableClientAsync(hostId, request.owner.roleName)
153
+ : this.tmux.hasWritableClient?.(hostId, request.owner.roleName) === true);
154
+ if (writableHumanAttached) {
155
+ throw new RuntimeHostContentionError("writable-client", `A writable human is attached to ${request.owner.taskId}/${request.owner.roleName}.`);
156
+ }
157
+ if (request.owner.scope === "task"
158
+ && request.adapterId === "claude"
159
+ && request.runId !== undefined
160
+ && await probeRoleStatus(this.tmux, hostId, request.owner.roleName) === "running") {
161
+ // Managed Claude is process-per-Run. A live Role window here belongs to
162
+ // an earlier process (or to recovery of the exact reserved Run); never
163
+ // plan, persist pre-start state, or inject input into it. The coordinator
164
+ // decides between same-generation recovery and a retry after natural
165
+ // exit from the durable reservation identity.
166
+ return createRuntimeBinding({
167
+ id: bindingId,
168
+ launchId: request.launchId,
169
+ owner: request.owner,
170
+ agentId: request.agentId,
171
+ adapterId: request.adapterId,
172
+ hostRef: encodeHostRef({
173
+ scope: request.owner.scope,
174
+ hostId,
175
+ roleName: request.owner.roleName
176
+ }),
177
+ hostCreated: false,
178
+ ...(request.mode === "resume"
179
+ ? { nativeSessionId: request.nativeSessionId }
180
+ : {})
181
+ });
182
+ }
148
183
  const input = {
149
184
  roleName: request.owner.roleName,
150
185
  agentId: request.agentId,
@@ -3,8 +3,7 @@ import { isSchedulerTaskWorkspaceReady } from "./ports.js";
3
3
  import { formatAgentRunReceiptId } from "../task/taskRecordReference.js";
4
4
  import { effectiveLaunchSnapshotsCompatible, effectiveLaunchSnapshotsCompatibleForTaskMain } from "../executor/effectiveLaunch.js";
5
5
  import { RuntimeLaunchError } from "../runtime/ports.js";
6
- import { isPreInputReadinessSupported } from "../lifecycle/canonicalLifecycleEvent.js";
7
- import { preInputReadinessCapability } from "../lifecycle/providerLifecycleMapping.js";
6
+ import { builtinAgentDriverRegistry } from "../runtime/builtinAgentDrivers.js";
8
7
  /**
9
8
  * Delivers durable Work AgentRuns before liveness reconciliation. Task command
10
9
  * handlers only record intent; this Controller path is the sole automated
@@ -95,8 +94,9 @@ export async function processActiveRoleRunDeliveries(store, delivery, now, selec
95
94
  preStartFencePersisted = true;
96
95
  }
97
96
  });
98
- // A fresh Codex host may already carry the exact Run prompt in its
99
- // launch argv. Once preparation returns that transport fact, any
97
+ // A managed host may already have submitted the exact Run prompt as
98
+ // part of process launch. Once preparation returns that transport
99
+ // fact, any
100
100
  // later readiness or aggregate-write failure is delivery uncertainty,
101
101
  // not a launch failure: preserve the Run and its reservation for the
102
102
  // matching provider Hook instead of terminalizing it.
@@ -109,9 +109,7 @@ export async function processActiveRoleRunDeliveries(store, delivery, now, selec
109
109
  // pre-readiness Session falls back to the existing durable Session;
110
110
  // fresh runtime-discovered providers intentionally persist `null`.
111
111
  if (!preStartFencePersisted) {
112
- const preparedFenceSession = prepared.session === undefined
113
- ? existingSession
114
- : validateReadySession(role, run.effective, existingSession, run.mode, { prepared, session: prepared.session });
112
+ const preparedFenceSession = validateLaunchSubmittedRecoverySession(role, run.effective, existingSession, run.mode, prepared, prepared.session);
115
113
  preparedSession = preparedFenceSession;
116
114
  store.saveRoleRunPrepared({
117
115
  task,
@@ -125,7 +123,7 @@ export async function processActiveRoleRunDeliveries(store, delivery, now, selec
125
123
  const ready = await delivery.waitUntilReady(prepared);
126
124
  deliveryAttempted = deliveryAttempted
127
125
  || ready.prepared.inputSubmittedAtLaunch === true;
128
- const session = validateReadySession(role, run.effective, existingSession, run.mode, ready);
126
+ const session = validateLaunchSubmittedRecoverySession(role, run.effective, existingSession, run.mode, ready.prepared, ready.session);
129
127
  preparedSession = session;
130
128
  store.saveRoleRunPrepared({
131
129
  task,
@@ -178,7 +176,8 @@ export async function processActiveRoleRunDeliveries(store, delivery, now, selec
178
176
  // screen scrape, or pane/PID inference — and fails closed for a
179
177
  // supported adapter whose readiness cannot be confirmed.
180
178
  if (ready.prepared.sessionStarted
181
- && isPreInputReadinessSupported(preInputReadinessCapability(run.effective.adapterId))
179
+ && builtinAgentDriverRegistry().requireByAdapterId(run.effective.adapterId)
180
+ .capabilities.observation.preInputReadiness === "exact"
182
181
  && !providerReadyForPush(store, {
183
182
  taskId: task.id,
184
183
  roleName: role.name,
@@ -232,14 +231,15 @@ export async function processActiveRoleRunDeliveries(store, delivery, now, selec
232
231
  if (error instanceof RuntimeLaunchError) {
233
232
  const terminalFailure = roleRunDeliveryFailure(run, processing.batchId, existingSession, error.launchId);
234
233
  if (error.retryable) {
234
+ const writerAttached = error.reason === "writable-client";
235
235
  results.push({
236
236
  taskId: task.id,
237
237
  roleName: role.name,
238
238
  runId: run.id,
239
239
  status: "skipped",
240
- reason: "runtime-unavailable",
240
+ reason: writerAttached ? "writer-attached" : "runtime-unavailable",
241
241
  error: message,
242
- terminalFailure
242
+ ...(writerAttached ? {} : { terminalFailure })
243
243
  });
244
244
  continue;
245
245
  }
@@ -364,8 +364,23 @@ function preflightSession(role, effective, existing, mode, preflight) {
364
364
  };
365
365
  return validateRoleSession(role, effective, existing, mode, session);
366
366
  }
367
- function validateReadySession(role, effective, existing, mode, ready) {
368
- return validateRoleSession(role, effective, existing, mode, ready.session);
367
+ /**
368
+ * A Controller restart can recover an exact launch-submitted Run while its
369
+ * finite provider process is still alive. The host intentionally does not
370
+ * re-plan or re-submit that Run and therefore may return no native identity;
371
+ * retain only the durable Session fenced to the same launch generation.
372
+ */
373
+ function validateLaunchSubmittedRecoverySession(role, effective, existing, mode, prepared, session) {
374
+ if (session === null
375
+ && prepared.inputSubmittedAtLaunch === true
376
+ && prepared.sessionStarted === false
377
+ && existing?.launchId !== undefined
378
+ && existing.launchId === prepared.launchId) {
379
+ return validateRoleSession(role, effective, existing, mode, existing);
380
+ }
381
+ return session === undefined
382
+ ? existing
383
+ : validateRoleSession(role, effective, existing, mode, session);
369
384
  }
370
385
  function validateRoleSession(role, effective, existing, mode, session) {
371
386
  if (mode === "new" && session === null)
@@ -7,6 +7,7 @@ import { hasRuntimeLifecycleWork, runtimeLifecycleTarget } from "../runtime/life
7
7
  import { recordLeaderFailure } from "./leaderFailure.js";
8
8
  import { createLeaderRecoveryNotification } from "./operatorNotification.js";
9
9
  import { isSchedulerTaskWorkspaceReady } from "./ports.js";
10
+ import { RuntimeLaunchError } from "../runtime/ports.js";
10
11
  export async function processLeaderWakeups(store, delivery, now, selection) {
11
12
  const results = [];
12
13
  const wakeups = selection === undefined || selection.full
@@ -152,8 +153,9 @@ export async function processLeaderWakeups(store, delivery, now, selection) {
152
153
  preStartFencePersisted = true;
153
154
  }
154
155
  });
155
- // A fresh Codex host may already carry the exact Run prompt in its
156
- // launch argv. Once preparation returns that transport fact, any
156
+ // A managed host may already have submitted the exact Run prompt as
157
+ // part of process launch. Once preparation returns that transport
158
+ // fact, any
157
159
  // later readiness or aggregate-write failure is delivery uncertainty,
158
160
  // not a launch failure: preserve the Run and its reservation for the
159
161
  // matching provider Hook instead of terminalizing it.
@@ -262,6 +264,23 @@ export async function processLeaderWakeups(store, delivery, now, selection) {
262
264
  const detail = error instanceof Error ? error.message : String(error);
263
265
  const message = `Leader dispatch failed: ${detail}`;
264
266
  if (claimed && run !== null) {
267
+ // A finite managed provider from the preceding Run may still be
268
+ // exiting after its durable yield. Keep this newly-claimed Run as the
269
+ // sole owner of the Role mailbox; active-Run delivery will retry it
270
+ // after the old host disappears. This is runtime backpressure, not a
271
+ // Leader failure and not grounds for allocating another Run.
272
+ if (error instanceof RuntimeLaunchError && error.retryable) {
273
+ results.push({
274
+ taskId: task.id,
275
+ runId: run.id,
276
+ status: "skipped",
277
+ reason: error.reason === "writable-client"
278
+ ? "writer-attached"
279
+ : "not-ready",
280
+ error: message
281
+ });
282
+ continue;
283
+ }
265
284
  // Once delivery begins, a send may have succeeded even when receipt
266
285
  // observation or the aggregate write failed. Preserve the exact
267
286
  // durable Run and let receipt-backed active delivery recover it.
@@ -1,7 +1,7 @@
1
1
  import { selectedSchedulerRoles, selectedSchedulerTasks } from "./ports.js";
2
2
  import { formatTaskRecordReference } from "../task/taskRecordReference.js";
3
3
  import { queueLeaderWakeup } from "./wakeupQueue.js";
4
- import { currentRoleRunProgressAt, DEFAULT_EXECUTION_STALL_CANDIDATE_AGE_MS } from "./roleRunStall.js";
4
+ import { currentRoleRunProgressAt, DEFAULT_WORKFLOW_STALL_CANDIDATE_AGE_MS } from "./roleRunStall.js";
5
5
  export const EXITED_ROLE_RUN_SUMMARY = "The role's tmux session exited before the run yielded.";
6
6
  /**
7
7
  * Lightweight liveness only: an active AgentRun whose tmux role is absent is
@@ -143,5 +143,5 @@ function isResourceCandidate(task, run, now) {
143
143
  }
144
144
  const deliveredAt = Date.parse(run.deliveredAt);
145
145
  return Number.isFinite(deliveredAt)
146
- && now.getTime() - deliveredAt >= DEFAULT_EXECUTION_STALL_CANDIDATE_AGE_MS;
146
+ && now.getTime() - deliveredAt >= DEFAULT_WORKFLOW_STALL_CANDIDATE_AGE_MS;
147
147
  }