@zq-silk/yui 0.6.4 → 0.6.6

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.
@@ -282,6 +282,9 @@ export class FileSchedulerStoreAdapter {
282
282
  return { timeZone: this.store.getConfig().timeZone };
283
283
  }
284
284
  listTasks() { return this.store.listTasks(); }
285
+ listActiveTaskIds() {
286
+ return [...this.store.listActiveTaskIds()].sort((left, right) => (left.localeCompare(right, undefined, { numeric: true })));
287
+ }
285
288
  getTask(taskId) { return this.store.getTask(taskId); }
286
289
  getTaskWorkspace(taskId) { return this.store.getTaskWorkspace(taskId); }
287
290
  getTaskBrief(taskId) { return this.store.getTaskBrief(taskId); }
@@ -298,10 +301,10 @@ export class FileSchedulerStoreAdapter {
298
301
  return this.store.getActiveAgentRun(taskId, roleName);
299
302
  }
300
303
  hasOpenInputRequest(taskId) {
301
- return this.store.listInputRequests(taskId).some((request) => request.status === "open");
304
+ return this.store.listOpenInputRequests([taskId]).length > 0;
302
305
  }
303
- listOpenInputRequests() {
304
- return this.store.listAllInputRequests().filter((request) => request.status === "open");
306
+ listOpenInputRequests(taskIds) {
307
+ return this.store.listOpenInputRequests(taskIds);
305
308
  }
306
309
  getInputRequest(taskId, inputRequestId) {
307
310
  return this.store.getInputRequest(taskId, inputRequestId);
@@ -321,10 +324,11 @@ export class FileSchedulerStoreAdapter {
321
324
  }
322
325
  resolveExpiredInputRecommendations(now, taskIds) {
323
326
  return this.store.transaction((store) => {
324
- const expired = store.listAllInputRequests().filter((request) => (request.status === "open"
325
- && request.policy.kind === "recommended"
326
- && Date.parse(request.policy.timeoutAt) <= now.getTime()
327
- && (taskIds === undefined || taskIds.has(request.taskId))));
327
+ const selectedTaskIds = taskIds === undefined
328
+ ? undefined
329
+ : [...taskIds].sort((left, right) => (left.localeCompare(right, undefined, { numeric: true })));
330
+ const expired = store.listOpenInputRequests(selectedTaskIds).filter((request) => (request.policy.kind === "recommended"
331
+ && Date.parse(request.policy.timeoutAt) <= now.getTime()));
328
332
  const resolved = [];
329
333
  for (const request of expired) {
330
334
  const task = store.getTask(request.taskId);
@@ -513,6 +517,7 @@ export class FileSchedulerStoreAdapter {
513
517
  }
514
518
  getWorkMailbox(target) { return this.store.getWorkMailbox(target); }
515
519
  listWorkMailboxes() { return this.store.listWorkMailboxes(); }
520
+ listReadyWorkMailboxes() { return this.store.listReadyWorkMailboxes(); }
516
521
  queueTaskProgress(taskId, reason, now) {
517
522
  this.store.transaction((store) => {
518
523
  enqueueWork(store, { kind: "task", taskId }, reason, now, [
@@ -523,8 +528,8 @@ export class FileSchedulerStoreAdapter {
523
528
  enqueueLeaderWakeup(taskId, reason, now) {
524
529
  return this.store.transaction((store) => (queueLeaderWakeup(store, taskId, reason, now)));
525
530
  }
526
- listAllDurableJobs() {
527
- return this.store.listAllDurableJobs();
531
+ listActiveDurableJobs() {
532
+ return this.store.listActiveDurableJobs();
528
533
  }
529
534
  /**
530
535
  * Apply one durable-job transition and, in the SAME transaction, enqueue
@@ -647,38 +652,24 @@ export class FileSchedulerStoreAdapter {
647
652
  });
648
653
  }
649
654
  listDormantRuntimeOwners() {
650
- const taskOwners = this.store.listTasks().flatMap((task) => (this.store.listRoleSessionSets(task.id).flatMap((sessions) => {
651
- const active = sessions.sessions[sessions.activeAgentId];
652
- return active !== undefined
653
- && active.status !== "stopped"
654
- && !hasRuntimeLifecycleWork(this.store.getWorkMailbox(runtimeLifecycleTarget(sessions.owner)))
655
- && this.store.getActiveAgentRun(task.id, sessions.owner.roleName) === null
656
- ? [{
657
- owner: sessions.owner,
658
- agentId: active.agentId,
659
- adapterId: active.adapterId,
660
- nativeSessionId: active.nativeSessionId,
661
- ...(active.launchId === undefined ? {} : { launchId: active.launchId }),
662
- sessionUpdatedAt: active.updatedAt
663
- }]
664
- : [];
665
- })));
666
- const globalOwners = this.store.listGlobalRoleSessionSets().flatMap((sessions) => {
667
- const active = sessions.sessions[sessions.activeAgentId];
668
- return active !== undefined
669
- && active.status !== "stopped"
670
- && !hasRuntimeLifecycleWork(this.store.getWorkMailbox(runtimeLifecycleTarget(sessions.owner)))
671
- ? [{
672
- owner: sessions.owner,
673
- agentId: active.agentId,
674
- adapterId: active.adapterId,
675
- nativeSessionId: active.nativeSessionId,
676
- ...(active.launchId === undefined ? {} : { launchId: active.launchId }),
677
- sessionUpdatedAt: active.updatedAt
678
- }]
679
- : [];
655
+ return this.listRuntimeSessionCandidates().flatMap((candidate) => {
656
+ if (hasRuntimeLifecycleWork(this.store.getWorkMailbox(runtimeLifecycleTarget(candidate.owner)))
657
+ || (candidate.owner.scope === "task"
658
+ && this.store.getActiveAgentRun(candidate.owner.taskId, candidate.owner.roleName) !== null)) {
659
+ return [];
660
+ }
661
+ return [{
662
+ owner: candidate.owner,
663
+ agentId: candidate.agentId,
664
+ adapterId: candidate.adapterId,
665
+ nativeSessionId: candidate.nativeSessionId,
666
+ ...(candidate.launchId === undefined ? {} : { launchId: candidate.launchId }),
667
+ sessionUpdatedAt: candidate.sessionUpdatedAt
668
+ }];
680
669
  });
681
- return [...taskOwners, ...globalOwners];
670
+ }
671
+ listRuntimeSessionCandidates(query = {}) {
672
+ return this.store.listRuntimeSessionCandidates(query);
682
673
  }
683
674
  markRuntimeOwnerStopped(candidate, now) {
684
675
  return this.store.transaction((store) => {
@@ -755,6 +746,10 @@ export class FileSchedulerStoreAdapter {
755
746
  owner: "controller",
756
747
  startedAt: input.now.toISOString()
757
748
  }), batchId, { type: "run", taskId: input.task.id, id: input.run.id });
749
+ // SQLite stores the durable Run row and its active pointer separately;
750
+ // FileTaskStore happens to persist both from saveActiveAgentRun. Keep
751
+ // the adapter contract backend-neutral and make the two writes atomic.
752
+ store.saveAgentRun(input.run);
758
753
  store.saveActiveAgentRun(input.run);
759
754
  store.saveWorkMailbox(claimed);
760
755
  store.saveRole(input.task.id, updateRoleStatus(role, "running", input.now));
@@ -1380,7 +1375,9 @@ export class FileSchedulerStoreAdapter {
1380
1375
  nativeSessionId: input.nativeSessionId,
1381
1376
  ...(input.launchId === undefined ? {} : { launchId: input.launchId }),
1382
1377
  policy: "fixed",
1383
- status: sessions.inFlight === null ? idleStatus : "running",
1378
+ status: idleStatus === "stopped" || idleStatus === "broken"
1379
+ ? idleStatus
1380
+ : sessions.inFlight === null ? idleStatus : "running",
1384
1381
  effective
1385
1382
  }, now);
1386
1383
  if (sessions.inFlight === null) {
@@ -1416,7 +1413,10 @@ export class FileSchedulerStoreAdapter {
1416
1413
  runId: fence.runId,
1417
1414
  turnId: input.turnId
1418
1415
  }, now);
1419
- sessions = updateRoleAgentSessionStatus(sessions, input.agentId, "ready", now);
1416
+ const settledStatus = sessions.sessions[input.agentId]?.status;
1417
+ if (settledStatus !== "stopped" && settledStatus !== "broken") {
1418
+ sessions = updateRoleAgentSessionStatus(sessions, input.agentId, "ready", now);
1419
+ }
1420
1420
  store.saveTaskRoleSessionSet(sessions);
1421
1421
  completeRuntimeHookReservation(store, owner, input.launchId);
1422
1422
  return { session: sessions.sessions[input.agentId], duplicate: false };
@@ -1430,13 +1430,15 @@ export class FileSchedulerStoreAdapter {
1430
1430
  };
1431
1431
  });
1432
1432
  }
1433
- listPendingRuntimeTurnCompletions() {
1434
- return this.store.listTasks().flatMap((task) => (this.store.listRoleSessionSets(task.id).flatMap((sessions) => (sessions.pendingTurnCompletion === null ? [] : [sessions.pendingTurnCompletion]))));
1433
+ listPendingRuntimeTurnCompletions(taskIds) {
1434
+ return this.store.listPendingRuntimeTurnCompletions(taskIds);
1435
1435
  }
1436
1436
  /** Resolves only completions whose grace deadline has elapsed. */
1437
1437
  resolveDueRuntimeTurnCompletions(now, taskIds) {
1438
- const due = this.listPendingRuntimeTurnCompletions().filter((completion) => (Date.parse(completion.dueAt) <= now.getTime()
1439
- && (taskIds === undefined || taskIds.has(completion.taskId))));
1438
+ const selectedTaskIds = taskIds === undefined
1439
+ ? undefined
1440
+ : [...taskIds].sort((left, right) => (left.localeCompare(right, undefined, { numeric: true })));
1441
+ const due = this.listPendingRuntimeTurnCompletions(selectedTaskIds).filter((completion) => Date.parse(completion.dueAt) <= now.getTime());
1440
1442
  const finalized = [];
1441
1443
  for (const completion of due) {
1442
1444
  this.store.transaction((store) => {
@@ -1472,7 +1474,10 @@ export class FileSchedulerStoreAdapter {
1472
1474
  runId: completion.runId,
1473
1475
  turnId: completion.turnId
1474
1476
  }, now);
1475
- settled = updateRoleAgentSessionStatus(settled, completion.agentId, "ready", now);
1477
+ const settledStatus = settled.sessions[completion.agentId]?.status;
1478
+ if (settledStatus !== "stopped" && settledStatus !== "broken") {
1479
+ settled = updateRoleAgentSessionStatus(settled, completion.agentId, "ready", now);
1480
+ }
1476
1481
  store.saveTaskRoleSessionSet(settled);
1477
1482
  });
1478
1483
  }
@@ -1506,6 +1511,8 @@ export class FileSchedulerStoreAdapter {
1506
1511
  throw new Error("Runtime turn completion does not match the effective launch identity.");
1507
1512
  }
1508
1513
  const sessions = effectiveExisting?.status === "ready"
1514
+ || effectiveExisting?.status === "stopped"
1515
+ || effectiveExisting?.status === "broken"
1509
1516
  ? current
1510
1517
  : recordRoleAgentSession(current, {
1511
1518
  agentId: input.agentId,
@@ -1816,8 +1823,8 @@ export class FileSchedulerStoreAdapter {
1816
1823
  * due. The Controller arms its deadline timer from this projection, so a
1817
1824
  * Controller restart resumes the same attempt lineage.
1818
1825
  */
1819
- listPendingProviderRetries() {
1820
- return this.store.listPendingProviderRetries();
1826
+ listPendingProviderRetries(taskIds) {
1827
+ return this.store.listPendingProviderRetries(taskIds);
1821
1828
  }
1822
1829
  /**
1823
1830
  * Issue 04: reopens each due retry on its original Native Session. A Run
@@ -1826,8 +1833,10 @@ export class FileSchedulerStoreAdapter {
1826
1833
  * re-pushes the exact same input in the same pass.
1827
1834
  */
1828
1835
  resolveDueProviderRetries(now, taskIds) {
1829
- const due = this.listPendingProviderRetries().filter((entry) => (Date.parse(entry.nextAttemptAt) <= now.getTime()
1830
- && (taskIds === undefined || taskIds.has(entry.taskId))));
1836
+ const selectedTaskIds = taskIds === undefined
1837
+ ? undefined
1838
+ : [...taskIds].sort((left, right) => (left.localeCompare(right, undefined, { numeric: true })));
1839
+ const due = this.listPendingProviderRetries(selectedTaskIds).filter((entry) => Date.parse(entry.nextAttemptAt) <= now.getTime());
1831
1840
  const reopened = [];
1832
1841
  for (const entry of due) {
1833
1842
  this.store.transaction((store) => {
@@ -3,13 +3,12 @@
3
3
  *
4
4
  * The supervisor is pure logic over injected ports. It is invoked from the
5
5
  * Controller scheduler pass and on Controller startup. Each pass reconciles
6
- * every job toward its correct state:
6
+ * only jobs that can still change under supervision:
7
7
  *
8
8
  * queued -> write spec, spawn detached runner, transition to running
9
9
  * running -> harvest exit.json / liveness via pid+startIdentity /
10
10
  * evidence ladder (exit.json -> checkpoint -> unknown) /
11
11
  * stale heartbeat SIGTERM then SIGKILL escalation
12
- * terminal + !wakeupNotified -> set flag + enqueue Leader wakeup
13
12
  * cancelRequestedAt -> write cancel fence + SIGTERM
14
13
  */
15
14
  import { spawn } from "node:child_process";
@@ -41,7 +40,7 @@ export class DurableJobSupervisor {
41
40
  this.#onError = options.onError ?? (() => undefined);
42
41
  }
43
42
  reconcile(now) {
44
- const jobs = this.#store.listAllDurableJobs();
43
+ const jobs = this.#store.listActiveDurableJobs();
45
44
  for (const job of jobs) {
46
45
  try {
47
46
  this.#reconcileJob(job, now);
@@ -62,9 +61,6 @@ export class DurableJobSupervisor {
62
61
  this.#superviseRunning(job, now);
63
62
  return;
64
63
  }
65
- if (isDurableJobTerminal(job.status) && job.wakeupNotified !== true) {
66
- this.#notifyWakeup(job, now);
67
- }
68
64
  }
69
65
  /** Composite key for the SIGKILL deadline map. */
70
66
  #sigkillKey(job) {
@@ -335,16 +331,6 @@ export class DurableJobSupervisor {
335
331
  outcome: job.result?.outcome ?? job.status
336
332
  });
337
333
  }
338
- /**
339
- * Recovery fallback for terminal jobs that reached their terminal state
340
- * before the atomic wakeup composition existed (or whose wakeup was lost).
341
- * The normal path (#harvestExit / #handleDeadProcess) already composes
342
- * complete + wakeupNotified + enqueue in one transaction; this pass only
343
- * fires for jobs that somehow slipped through without wakeupNotified.
344
- */
345
- #notifyWakeup(job, now) {
346
- this.#store.transitionDurableJob(job.taskId, job.id, (current) => markDurableJobWakeupNotified(current, now), now, { reason: "job-finished", refs: wakeupRefs(job) });
347
- }
348
334
  }
349
335
  function wakeupRefs(job) {
350
336
  const refs = [{ type: "task", id: job.taskId }];
@@ -574,7 +574,9 @@ function loadHomeState(home, warnings, options) {
574
574
  }
575
575
  try {
576
576
  const store = (options.openCompatibleStore ?? openCompatibleFileTaskStore)(home);
577
- const roles = store.listGlobalRoles().map((role) => {
577
+ const roles = [...store.listGlobalRoles()]
578
+ .sort((left, right) => numericCompare(left.name, right.name))
579
+ .map((role) => {
578
580
  const session = activeLiveRoleAgentSession(store.getGlobalRoleSessionSet(role.name));
579
581
  const binding = role.agentBindings[role.activeAgentId];
580
582
  const agentId = session?.effective.agentId ?? role.activeAgentId;
@@ -588,25 +590,73 @@ function loadHomeState(home, warnings, options) {
588
590
  ...(session?.launchId === undefined ? {} : { launchId: session.launchId })
589
591
  };
590
592
  });
591
- for (const task of store.listTasks()) {
592
- for (const role of store.listRoles(task.id)) {
593
- const session = activeLiveRoleAgentSession(store.getRoleSessionSet(task.id, role.name));
594
- const binding = role.agentBindings[role.activeAgentId];
595
- const run = store.getActiveAgentRun(task.id, role.name);
596
- const agentId = session?.effective.agentId ?? role.activeAgentId;
597
- const adapterId = session?.effective.adapterId ?? binding?.adapterId;
598
- roles.push({
599
- ownerKind: "task-role",
600
- taskId: task.id,
601
- taskTitle: task.title,
602
- taskStatus: task.status,
603
- roleName: role.name,
604
- agentId,
605
- ...(adapterId === undefined ? {} : { adapterId }),
606
- ...(session === null ? {} : { nativeSessionId: session.nativeSessionId }),
607
- ...(session?.launchId === undefined ? {} : { launchId: session.launchId }),
608
- ...(run === null ? {} : { runId: run.id })
609
- });
593
+ const appendTaskRole = (task, role) => {
594
+ const session = activeLiveRoleAgentSession(store.getRoleSessionSet(task.id, role.name));
595
+ const binding = role.agentBindings[role.activeAgentId];
596
+ const run = store.getActiveAgentRun(task.id, role.name);
597
+ const agentId = session?.effective.agentId ?? role.activeAgentId;
598
+ const adapterId = session?.effective.adapterId ?? binding?.adapterId;
599
+ roles.push({
600
+ ownerKind: "task-role",
601
+ taskId: task.id,
602
+ taskTitle: task.title,
603
+ taskStatus: task.status,
604
+ roleName: role.name,
605
+ agentId,
606
+ ...(adapterId === undefined ? {} : { adapterId }),
607
+ ...(session === null ? {} : { nativeSessionId: session.nativeSessionId }),
608
+ ...(session?.launchId === undefined ? {} : { launchId: session.launchId }),
609
+ ...(run === null ? {} : { runId: run.id })
610
+ });
611
+ };
612
+ // Layout-7 SQLite owns bounded active-Task and current-Session indexes.
613
+ // Use those indexes only as selectors, then point-read the authoritative
614
+ // Task/Role/Session/Run records so an index row cannot bypass ownership
615
+ // fences. The legacy File store intentionally retains its complete
616
+ // listTasks fallback because it has no equivalent active selector.
617
+ const hotStore = store;
618
+ const indexedTaskIds = hotStore.listActiveTaskIds === undefined
619
+ ? undefined
620
+ : [...new Set(hotStore.listActiveTaskIds())].sort(numericCompare);
621
+ if (indexedTaskIds === undefined) {
622
+ for (const task of store.listTasks()) {
623
+ for (const role of [...store.listRoles(task.id)].sort((left, right) => (numericCompare(left.name, right.name)))) {
624
+ appendTaskRole(task, role);
625
+ }
626
+ }
627
+ }
628
+ else {
629
+ const sessionCandidates = typeof store.listRuntimeSessionCandidates === "function"
630
+ ? store.listRuntimeSessionCandidates({ scope: "task" })
631
+ : [];
632
+ const candidateRoles = new Map();
633
+ for (const candidate of sessionCandidates) {
634
+ if (candidate.owner.scope !== "task")
635
+ continue;
636
+ const roleNames = candidateRoles.get(candidate.owner.taskId) ?? new Set();
637
+ roleNames.add(candidate.owner.roleName);
638
+ candidateRoles.set(candidate.owner.taskId, roleNames);
639
+ }
640
+ const activeTaskSet = new Set(indexedTaskIds);
641
+ const taskIds = [...new Set([
642
+ ...indexedTaskIds,
643
+ ...candidateRoles.keys()
644
+ ])].sort(numericCompare);
645
+ for (const taskId of taskIds) {
646
+ const task = store.getTask(taskId);
647
+ if (task === null)
648
+ continue;
649
+ const rolesForTask = activeTaskSet.has(taskId) && task.status === "active"
650
+ ? store.listRoles(taskId)
651
+ : [...(candidateRoles.get(taskId) ?? [])]
652
+ .sort(numericCompare)
653
+ .flatMap((roleName) => {
654
+ const role = store.getRole(taskId, roleName);
655
+ return role === null ? [] : [role];
656
+ });
657
+ for (const role of [...rolesForTask].sort((left, right) => (numericCompare(left.name, right.name)))) {
658
+ appendTaskRole(task, role);
659
+ }
610
660
  }
611
661
  }
612
662
  return { storageStatus: schema.status, roles };
@@ -616,6 +666,9 @@ function loadHomeState(home, warnings, options) {
616
666
  return { storageStatus: "invalid", roles: [] };
617
667
  }
618
668
  }
669
+ function numericCompare(left, right) {
670
+ return left.localeCompare(right, undefined, { numeric: true });
671
+ }
619
672
  function inspectHomePanes(home, environment, warnings) {
620
673
  try {
621
674
  const executor = new NodeCommandExecutor();
@@ -373,6 +373,7 @@ export async function startFileTaskControllerRuntime(home, options = {}) {
373
373
  intervalMs: options.intervalMs
374
374
  ?? reconciliationIntervalMilliseconds(store.getConfig().reconciliationIntervalSeconds),
375
375
  signalWindowMs: options.signalWindowMs,
376
+ taskConcurrency: options.taskConcurrency,
376
377
  deliveryRetryMs: options.deliveryRetryMs,
377
378
  deliveryRetryLimit: options.deliveryRetryLimit,
378
379
  now: options.now,