@bastani/atomic 0.9.61-alpha.2 → 0.9.61-alpha.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 (28) hide show
  1. package/CHANGELOG.md +27 -2
  2. package/dist/builtin/intercom/package.json +1 -1
  3. package/dist/builtin/mcp/package.json +1 -1
  4. package/dist/builtin/subagents/package.json +1 -1
  5. package/dist/builtin/web-access/package.json +1 -1
  6. package/dist/builtin/workflows/package.json +1 -1
  7. package/dist/builtin/workflows/src/extension/index.bundle.mjs +49 -9
  8. package/dist/builtin/workflows/src/extension/local-worker-entry.mjs +93 -13
  9. package/dist/builtin/workflows/src/index.js +49 -9
  10. package/dist/builtin/workflows/src/local-worker-entry.mjs +93 -13
  11. package/dist/modes/interactive-engine/engine-render-service.d.ts +8 -0
  12. package/dist/modes/interactive-engine/engine-render-service.d.ts.map +1 -1
  13. package/dist/modes/interactive-engine/engine-render-service.js +28 -6
  14. package/dist/modes/interactive-engine/engine-render-service.js.map +1 -1
  15. package/dist/modes/interactive-engine/remote-renderer.d.ts +6 -0
  16. package/dist/modes/interactive-engine/remote-renderer.d.ts.map +1 -1
  17. package/dist/modes/interactive-engine/remote-renderer.js +17 -3
  18. package/dist/modes/interactive-engine/remote-renderer.js.map +1 -1
  19. package/dist/modes/rpc/queued-writer.d.ts +2 -0
  20. package/dist/modes/rpc/queued-writer.d.ts.map +1 -1
  21. package/dist/modes/rpc/queued-writer.js +8 -0
  22. package/dist/modes/rpc/queued-writer.js.map +1 -1
  23. package/dist/modes/rpc/rpc-client.d.ts.map +1 -1
  24. package/dist/modes/rpc/rpc-client.js +4 -0
  25. package/dist/modes/rpc/rpc-client.js.map +1 -1
  26. package/docs/workflows/operations.md +1 -1
  27. package/npm-shrinkwrap.json +32 -32
  28. package/package.json +3 -3
package/CHANGELOG.md CHANGED
@@ -6,9 +6,9 @@
6
6
 
7
7
  Local worker pools that hold up under faults and load, a lighter terminal under heavy workflow load, and workflows that can work in any repository's worktree.
8
8
 
9
- - **One failing child no longer takes down a pool:** a child that fails to dispatch fails alone, a worker or tool process group that survives SIGKILL is a warning rather than a failure, and pool faults replay the child within its three attempts ([#4159](https://github.com/bastani-inc/atomic/pull/4159))
9
+ - **One failing child no longer takes down a pool:** a child that fails to dispatch fails alone, a worker or tool process group that survives SIGKILL is a warning rather than a failure, and pool faults replay the child within its three attempts, a replay blocked by those groups' quarantined permits fails after a bounded wait instead of waiting forever, and a fenced attempt releases its session permits so its replay never waits on them ([#4159](https://github.com/bastani-inc/atomic/pull/4159), [#4170](https://github.com/bastani-inc/atomic/pull/4170), [#4172](https://github.com/bastani-inc/atomic/pull/4172))
10
10
  - **Local worker pools keep their leases:** a slow journal or root no longer gets every healthy worker killed at once, and a race in lease recovery no longer fails every child of a pool with `stale_work_lease` ([#4165](https://github.com/bastani-inc/atomic/pull/4165))
11
- - **A lighter terminal under heavy load:** a workflow run no longer keeps every completed stage's session in memory until it ends, which cut peak heap from 2.8 GB to 0.87 GB in a 600-stage test and shortened GC pauses. Rebuilding the chat no longer flashes "Loading isolated renderer…" ([#4166](https://github.com/bastani-inc/atomic/pull/4166), [#4167](https://github.com/bastani-inc/atomic/pull/4167))
11
+ - **A lighter terminal under heavy load:** a workflow run no longer keeps every completed stage's session in memory until it ends, which cut peak heap from 2.8 GB to 0.87 GB in a 600-stage test and shortened GC pauses. Rebuilding the chat no longer flashes "Loading isolated renderer…", and rows keep the state you left them in ([#4166](https://github.com/bastani-inc/atomic/pull/4166), [#4167](https://github.com/bastani-inc/atomic/pull/4167), [#4169](https://github.com/bastani-inc/atomic/pull/4169))
12
12
  - **Broadcasts reach pooled stages at once:** stages on local workers get queued and sticky Intercom messages before their first turn, without a 60 s stall or a warning per stage ([#4164](https://github.com/bastani-inc/atomic/pull/4164))
13
13
  - **Content-filter refusals fall back:** a provider content-filter refusal, such as OpenAI's 422 `cyber_policy`, moves to the next `fallbackModels` entry instead of retrying the model that refused; turn it off with `retry.fallbackOnContentFilter` ([#4163](https://github.com/bastani-inc/atomic/pull/4163))
14
14
  - **Workflow worktrees from anywhere:** `git_worktree_dir` can name a worktree of any repository, and a non-empty folder gets its own checkout inside it ([#4162](https://github.com/bastani-inc/atomic/pull/4162))
@@ -36,6 +36,31 @@ Local worker pools that hold up under faults and load, a lighter terminal under
36
36
  - A stage on a local worker (`workflows.localWorkers`) that starts after a sticky Intercom broadcast (`workflow:<rootRunId>/**`) or a name or pattern send to future stages gets those messages before its first turn, under "Messages received before you started" with the real sender, even when the root is too busy to answer. Before, each such stage on a busy root could wait 60 seconds and then print a long "starting without its queued Intercom messages" warning in the chat, listing messages it may already have received. The worker now delivers the stage's queued messages itself and never waits on the root. If the root later fails to record or deliver them, the run's status shows one `Intercom delivery:` line for the whole run, not a warning per stage ([#4164](https://github.com/bastani-inc/atomic/pull/4164)).
37
37
  - A workflow run no longer keeps the live session of every stage it completed in memory until the whole run ends. A stage run with `ctx.task`, `ctx.chain` or `ctx.parallel` gives its session back as soon as it completes, and one run with `ctx.stage` once the workflow no longer holds its stage context; its saved transcript still opens from the graph, `/workflow attach` or the stage chat. A session hosting long runs with hundreds of completed stages held gigabytes this way, and the garbage collection that memory caused made the interactive TUI, `/tasks` and queued messages slow to respond. ([#4166](https://github.com/bastani-inc/atomic/pull/4166))
38
38
  - In the interactive TUI, rows that the engine renders, such as tool results and extension messages, keep their last frame when the chat is rebuilt (after compaction, `/reload` or switching sessions), instead of showing `Loading isolated renderer…` until a busy engine renders each one again. A rebuilt chat also no longer leaves the renderers of the rows it replaced running in the engine for the rest of the session, and engine messages reach chat rows through one subscription instead of one per row. ([#4167](https://github.com/bastani-inc/atomic/pull/4167))
39
+ - A rebuilt chat in the interactive TUI no longer briefly shows a collapsed tool row with its expanded output when the row was collapsed while its expanded frame was still on its way from the engine. Rows the chat drops while the engine connection is backed up also stay released, instead of being rendered again in the engine by a render request that arrived after their disposal. ([#4169](https://github.com/bastani-inc/atomic/pull/4169))
40
+ - A child on a local worker pool (`workflows.localWorkers`) whose replay needs a tool process permit no longer waits forever when the only capacity left under `admission.limits.process` (or `admission.userLimits.process`) is held by quarantined permits of process groups that survived SIGKILL. Those permits stay quarantined, and capacity held by running work is still waited for as before. A process admission that only quarantined permits block now waits at most 15 seconds for those groups to exit, then fails with the retryable `worker_cleanup_unconfirmed`, naming the groups and the limit. That ends the child's attempt, so it replays within its three attempts and then fails with `worker_retry_limit`, letting the root finish. If the groups exit in time, the replay goes on normally. ([#4170](https://github.com/bastani-inc/atomic/pull/4170))
41
+ - A child on a local worker pool (`workflows.localWorkers`) whose attempt ends on a fault of the pool now gives back the session permits of that attempt's stages when the attempt ends. Before, the attempt's stage sessions stayed open on the worker, and their permits stayed granted until the worker exited or the pool closed. When the child replayed on the same worker under a tight `admission.limits.session` (or `admission.userLimits.session`), the replay could wait for those permits indefinitely, since the worker kept its lease alive. The worker keeps its lease while those sessions close, so a session that takes longer than `leaseMs` to shut down does not get a live worker killed. Permits of tool process groups that survived SIGKILL stay quarantined as before, and an attempt that ended this way still cannot record a result. ([#4172](https://github.com/bastani-inc/atomic/pull/4172))
42
+
43
+ ## [0.9.61-alpha.4] - 2026-11-02
44
+
45
+ Replays on the same local worker no longer wait on their own earlier sessions.
46
+
47
+ - **Fenced attempts release their sessions:** when a local worker ends an attempt as a pool fault, it now closes that attempt's stage sessions first, so their session permits return. A replay on the same worker under a tight `admission.limits.session` no longer waits behind its own earlier attempt indefinitely ([#4172](https://github.com/bastani-inc/atomic/pull/4172))
48
+
49
+ ### Fixed
50
+
51
+ - A child on a local worker pool (`workflows.localWorkers`) whose attempt ends on a fault of the pool now gives back the session permits of that attempt's stages when the attempt ends. Before, the attempt's stage sessions stayed open on the worker, and their permits stayed granted until the worker exited or the pool closed. When the child replayed on the same worker under a tight `admission.limits.session` (or `admission.userLimits.session`), the replay could wait for those permits indefinitely, since the worker kept its lease alive. The worker keeps its lease while those sessions close, so a session that takes longer than `leaseMs` to shut down does not get a live worker killed. Permits of tool process groups that survived SIGKILL stay quarantined as before, and an attempt that ended this way still cannot record a result. ([#4172](https://github.com/bastani-inc/atomic/pull/4172))
52
+
53
+ ## [0.9.61-alpha.3] - 2026-11-02
54
+
55
+ Replays on a local worker that can no longer wait forever, and a steadier chat renderer.
56
+
57
+ - **Replays can't wait forever on cleanup:** when process groups that survived SIGKILL fill `admission.limits.process`, a child's replay now fails after a bounded wait with a retryable error naming those groups and the limit, instead of waiting indefinitely; the groups' permits stay quarantined ([#4170](https://github.com/bastani-inc/atomic/pull/4170))
58
+ - **Chat rows stay as you left them:** a row released under engine backpressure no longer comes back, and a row you collapse no longer shows its expanded output after the chat is rebuilt ([#4169](https://github.com/bastani-inc/atomic/pull/4169))
59
+
60
+ ### Fixed
61
+
62
+ - A rebuilt chat in the interactive TUI no longer briefly shows a collapsed tool row with its expanded output when the row was collapsed while its expanded frame was still on its way from the engine. Rows the chat drops while the engine connection is backed up also stay released, instead of being rendered again in the engine by a render request that arrived after their disposal. ([#4169](https://github.com/bastani-inc/atomic/pull/4169))
63
+ - A child on a local worker pool (`workflows.localWorkers`) whose replay needs a tool process permit no longer waits forever when the only capacity left under `admission.limits.process` (or `admission.userLimits.process`) is held by quarantined permits of process groups that survived SIGKILL. Those permits stay quarantined, and capacity held by running work is still waited for as before. A process admission that only quarantined permits block now waits at most 15 seconds for those groups to exit, then fails with the retryable `worker_cleanup_unconfirmed`, naming the groups and the limit. That ends the child's attempt, so it replays within its three attempts and then fails with `worker_retry_limit`, letting the root finish. If the groups exit in time, the replay goes on normally. ([#4170](https://github.com/bastani-inc/atomic/pull/4170))
39
64
 
40
65
  ## [0.9.61-alpha.2] - 2026-11-02
41
66
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bastani/intercom",
3
- "version": "0.9.61-alpha.2",
3
+ "version": "0.9.61-alpha.4",
4
4
  "private": true,
5
5
  "description": "Atomic extension providing a private coordination channel between parent and child agent sessions. Fork of: https://github.com/nicobailon/pi-intercom",
6
6
  "contributors": [
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bastani/mcp",
3
- "version": "0.9.61-alpha.2",
3
+ "version": "0.9.61-alpha.4",
4
4
  "private": true,
5
5
  "description": "Atomic's pi-compatible MCP integration for server connections, OAuth, tools, resources, and terminal management.",
6
6
  "contributors": [
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bastani/subagents",
3
- "version": "0.9.61-alpha.2",
3
+ "version": "0.9.61-alpha.4",
4
4
  "private": true,
5
5
  "description": "Atomic extension for delegating tasks to subagents with parallel execution. Fork of: https://github.com/nicobailon/pi-subagents",
6
6
  "contributors": [
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bastani/web-access",
3
- "version": "0.9.61-alpha.2",
3
+ "version": "0.9.61-alpha.4",
4
4
  "private": true,
5
5
  "description": "Atomic extension for web search, URL fetching, GitHub repo cloning, PDF/video extraction. Fork of: https://github.com/nicobailon/pi-web-access",
6
6
  "contributors": [
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bastani/workflows",
3
- "version": "0.9.61-alpha.2",
3
+ "version": "0.9.61-alpha.4",
4
4
  "private": true,
5
5
  "description": "Atomic extension for multi-stage workflow authoring and execution.",
6
6
  "contributors": [
@@ -20723,6 +20723,7 @@ class SqlResourceAdmission {
20723
20723
  epoch: this.options.attempt.epoch
20724
20724
  }) : null);
20725
20725
  });
20726
+ let quarantinedSince;
20726
20727
  try {
20727
20728
  for (;; ) {
20728
20729
  signal?.throwIfAborted();
@@ -20732,7 +20733,9 @@ class SqlResourceAdmission {
20732
20733
  }
20733
20734
  const hostReason = this.hostReason();
20734
20735
  const pressure = this.stageStartPressure(kind, request?.delegated);
20736
+ let quarantined = undefined;
20735
20737
  const granted = await this.storage.transaction(async (tx) => {
20738
+ quarantined = undefined;
20736
20739
  await tx.run(`UPDATE ${this.owners} SET expires_at = CAST(unixepoch('subsec') * 1000 AS INTEGER) + ? WHERE id = ?`, OWNER_LEASE_MS, this.owner);
20737
20740
  const { root } = this.options;
20738
20741
  if (!await this.assertCurrentAttempt(tx)) {
@@ -20750,6 +20753,8 @@ class SqlResourceAdmission {
20750
20753
  if (control && control.state !== "running")
20751
20754
  reason ??= `root ${control.state}`;
20752
20755
  }
20756
+ const held = [];
20757
+ let blockedOtherwise = reason !== undefined;
20753
20758
  for (const scope of [`root:${root}`, "user"]) {
20754
20759
  const row = await tx.get(`SELECT config FROM ${this.configuration} WHERE scope = ?`, scope);
20755
20760
  const limit = row ? JSON.parse(String(row.config))[kind] : undefined;
@@ -20759,9 +20764,21 @@ class SqlResourceAdmission {
20759
20764
  const args = scope === "user" ? [kind] : [kind, root];
20760
20765
  const count = await tx.get(`SELECT COUNT(*) AS count FROM ${this.permits} WHERE kind = ? ${rootClause} AND state = 'granted'`, ...args);
20761
20766
  const older = await tx.get(`SELECT 1 AS waiting FROM ${this.permits} WHERE kind = ? ${rootClause} AND state = 'waiting' AND reason IS NOT ? AND ticket < (SELECT ticket FROM ${this.permits} WHERE operation = ?) LIMIT 1`, ...args, STAGE_START_PRESSURE_REASON, operation);
20762
- if (Number(count?.count) >= limit || older)
20763
- reason ??= `${scope} ${kind}`;
20767
+ const used = Number(count?.count);
20768
+ if (used < limit && !older)
20769
+ continue;
20770
+ reason ??= `${scope} ${kind}`;
20771
+ const quarantinedPermits = kind === "process" && used >= limit ? await tx.all(`SELECT process_identity FROM ${this.permits} WHERE kind = ? ${rootClause} AND state = 'granted' AND reason = ?`, ...args, QUARANTINED_REASON) : [];
20772
+ if (quarantinedPermits.length > 0 && used - quarantinedPermits.length < limit)
20773
+ held.push({
20774
+ limit: `${scope === "user" ? "admission.userLimits" : "admission.limits"}.${kind} (${limit})`,
20775
+ groups: quarantinedPermits.flatMap(({ process_identity }) => process_identity === null ? [] : [JSON.parse(process_identity).id])
20776
+ });
20777
+ else
20778
+ blockedOtherwise = true;
20764
20779
  }
20780
+ if (!blockedOtherwise && held.length > 0)
20781
+ quarantined = held;
20765
20782
  if (reason && request?.delegated)
20766
20783
  throw new Error(`Delegated session cannot be admitted without capacity headroom: ${reason}`);
20767
20784
  const stageStart = kind === "session" && request?.delegated !== true;
@@ -20774,6 +20791,14 @@ class SqlResourceAdmission {
20774
20791
  signal?.throwIfAborted();
20775
20792
  return release;
20776
20793
  }
20794
+ if (quarantined === undefined)
20795
+ quarantinedSince = undefined;
20796
+ else {
20797
+ const now = performance.now();
20798
+ quarantinedSince ??= now;
20799
+ if (now - quarantinedSince >= QUARANTINED_CAPACITY_WAIT_MS)
20800
+ throw new QuarantinedCapacityError(quarantined, now - quarantinedSince);
20801
+ }
20777
20802
  await delay(50 + Math.floor(Math.random() * 25), undefined, { signal });
20778
20803
  }
20779
20804
  } catch (error) {
@@ -20815,9 +20840,9 @@ class SqlResourceAdmission {
20815
20840
  await tx.run(`UPDATE ${this.permits} SET state = 'released', reason = 'host reboot confirmed' WHERE owner = ? AND state = 'granted'`, owner.id);
20816
20841
  } else if (stopped) {
20817
20842
  await tx.run(`UPDATE ${this.permits} SET state = 'released', reason = 'owner death confirmed' WHERE owner = ? AND kind = 'session' AND state = 'granted'`, owner.id);
20818
- await tx.run(`UPDATE ${this.permits} SET reason = ? WHERE owner = ? AND kind = 'process' AND state = 'granted' AND reason IS NOT ?`, UNCONFIRMED_REASON, owner.id, UNCONFIRMED_REASON);
20843
+ await tx.run(`UPDATE ${this.permits} SET reason = ? WHERE owner = ? AND kind = 'process' AND state = 'granted' AND reason IS NOT ? AND reason IS NOT ?`, UNCONFIRMED_REASON, owner.id, UNCONFIRMED_REASON, QUARANTINED_REASON);
20819
20844
  } else {
20820
- await tx.run(`UPDATE ${this.permits} SET reason = ? WHERE owner = ? AND state = 'granted' AND reason IS NOT ? AND EXISTS (SELECT 1 FROM ${this.owners} WHERE id = ? AND expires_at < ${NOW_MS})`, EXPIRED_REASON, owner.id, EXPIRED_REASON, owner.id);
20845
+ await tx.run(`UPDATE ${this.permits} SET reason = ? WHERE owner = ? AND state = 'granted' AND reason IS NOT ? AND reason IS NOT ? AND EXISTS (SELECT 1 FROM ${this.owners} WHERE id = ? AND expires_at < ${NOW_MS})`, EXPIRED_REASON, owner.id, EXPIRED_REASON, QUARANTINED_REASON, owner.id);
20821
20846
  }
20822
20847
  if (owner.host === admissionHost && process.platform !== "win32") {
20823
20848
  const processes = await tx.all(`SELECT * FROM ${this.permits} WHERE owner = ? AND state = 'granted' AND kind = 'process' AND process_identity IS NOT NULL`, owner.id);
@@ -20845,9 +20870,10 @@ class SqlResourceAdmission {
20845
20870
  return true;
20846
20871
  if (permit.state === "waiting")
20847
20872
  return stopped || !!permit.expired;
20848
- if (stopped && (permit.kind === "session" || permit.reason !== UNCONFIRMED_REASON))
20873
+ const quarantined = permit.reason === QUARANTINED_REASON;
20874
+ if (stopped && (permit.kind === "session" || permit.reason !== UNCONFIRMED_REASON && !quarantined))
20849
20875
  return true;
20850
- if (!stopped && permit.expired && permit.reason !== EXPIRED_REASON)
20876
+ if (!stopped && permit.expired && permit.reason !== EXPIRED_REASON && !quarantined)
20851
20877
  return true;
20852
20878
  return permit.kind === "process" && permit.host === admissionHost && process.platform !== "win32" && permit.process_identity !== null && processGroupStopped(permit.process_identity);
20853
20879
  }
@@ -20858,7 +20884,9 @@ class SqlResourceAdmission {
20858
20884
  await this.reconcile();
20859
20885
  return this.storage.transaction(async (tx) => {
20860
20886
  await tx.run(`UPDATE ${this.permits} SET state = 'released', reason = 'worker exit confirmed' WHERE root = ? AND holder = ? AND state IN ('waiting', 'granted') AND (kind = 'session' OR state = 'waiting')`, this.options.root, holder);
20861
- return (await tx.run(`UPDATE ${this.permits} SET reason = 'cleanup unconfirmed' WHERE root = ? AND holder = ? AND kind = 'process' AND state = 'granted'`, this.options.root, holder)).changes;
20887
+ await tx.run(`UPDATE ${this.permits} SET reason = ? WHERE root = ? AND holder = ? AND kind = 'process' AND state = 'granted' AND reason IS NOT ?`, UNCONFIRMED_REASON, this.options.root, holder, QUARANTINED_REASON);
20888
+ const held = await tx.get(`SELECT COUNT(*) AS count FROM ${this.permits} WHERE root = ? AND holder = ? AND kind = 'process' AND state = 'granted'`, this.options.root, holder);
20889
+ return Number(held?.count);
20862
20890
  });
20863
20891
  }
20864
20892
  async reapStoppedProcesses(holder) {
@@ -20905,10 +20933,13 @@ async function settleProcessGroups(storage, root, scope, reap) {
20905
20933
  survivors = await settle(REAP_KILL_GRACE_MS);
20906
20934
  }
20907
20935
  const confirmed = [...groups].flatMap(([group, operations]) => survivors.includes(group) ? [] : operations);
20908
- if (confirmed.length > 0)
20936
+ const quarantined = reap ? survivors.flatMap((group) => groups.get(group) ?? []) : [];
20937
+ if (confirmed.length > 0 || quarantined.length > 0)
20909
20938
  await storage.transaction(async (tx) => {
20910
20939
  for (const operation of confirmed)
20911
20940
  await tx.run(`UPDATE ${permits} SET state = 'released', reason = 'process group cleanup confirmed' WHERE operation = ? AND state = 'granted'`, operation);
20941
+ for (const operation of quarantined)
20942
+ await tx.run(`UPDATE ${permits} SET reason = ? WHERE operation = ? AND state = 'granted'`, QUARANTINED_REASON, operation);
20912
20943
  });
20913
20944
  return { survivors, unprobeable };
20914
20945
  }
@@ -20925,11 +20956,20 @@ async function reapProcessGroups(journal, root, scope) {
20925
20956
  return { survivors: [], unprobeable: [] };
20926
20957
  return settleProcessGroups(storage, root, scope, true);
20927
20958
  }
20928
- var OWNER_LEASE_MS = 30000, REAP_TERM_GRACE_MS = 5000, REAP_KILL_GRACE_MS = 1e4, REAP_POLL_MS = 50, RELEASED_PERMIT_RETENTION = 256, RELEASED_PERMIT_PRUNE_BATCH = 1000, EXPIRED_REASON = "owner expired; cleanup unconfirmed", UNCONFIRMED_REASON = "cleanup unconfirmed", ANONYMOUS_REASON = "owner identity unavailable; cleanup unconfirmed", NOW_MS = "CAST(unixepoch('subsec') * 1000 AS INTEGER)", LATE_ATTEMPT_POLL_MS = 100, LATE_ATTEMPT_REASON = "waiting for its attempt lease", STAGE_START_SPACING_MS = 5000, STAGE_START_PRESSURE_REASON = "paced for host load";
20959
+ var OWNER_LEASE_MS = 30000, REAP_TERM_GRACE_MS = 5000, REAP_KILL_GRACE_MS = 1e4, REAP_POLL_MS = 50, RELEASED_PERMIT_RETENTION = 256, RELEASED_PERMIT_PRUNE_BATCH = 1000, EXPIRED_REASON = "owner expired; cleanup unconfirmed", UNCONFIRMED_REASON = "cleanup unconfirmed", QUARANTINED_REASON = "cleanup unconfirmed; permit quarantined", ANONYMOUS_REASON = "owner identity unavailable; cleanup unconfirmed", QUARANTINED_CAPACITY_WAIT_MS, QuarantinedCapacityError, NOW_MS = "CAST(unixepoch('subsec') * 1000 AS INTEGER)", LATE_ATTEMPT_POLL_MS = 100, LATE_ATTEMPT_REASON = "waiting for its attempt lease", STAGE_START_SPACING_MS = 5000, STAGE_START_PRESSURE_REASON = "paced for host load";
20929
20960
  var init_resource_admission = __esm(() => {
20930
20961
  init_internal();
20931
20962
  init_sql_run_journal();
20963
+ init_workflow_infrastructure_error();
20932
20964
  init_admission_host();
20965
+ QUARANTINED_CAPACITY_WAIT_MS = REAP_TERM_GRACE_MS + REAP_KILL_GRACE_MS;
20966
+ QuarantinedCapacityError = class QuarantinedCapacityError extends WorkflowInfrastructureError {
20967
+ constructor(capacity, waitedMs) {
20968
+ const limits = capacity.map(({ limit }) => limit).join(" and ");
20969
+ const groups = [...new Set(capacity.flatMap(({ groups }) => groups))].join(", ");
20970
+ super("worker_cleanup_unconfirmed", `Local worker process cleanup unconfirmed; process permit refused after ${Math.round(waitedMs)} ms: ${limits} ${capacity.length === 1 ? "is" : "are"} held by quarantined permits of process groups ${groups} that survived SIGKILL`);
20971
+ }
20972
+ };
20933
20973
  });
20934
20974
 
20935
20975
  // dist/builtin/workflows/src/workers/admission-binding.ts
@@ -17462,6 +17462,7 @@ class SqlResourceAdmission {
17462
17462
  epoch: this.options.attempt.epoch
17463
17463
  }) : null);
17464
17464
  });
17465
+ let quarantinedSince;
17465
17466
  try {
17466
17467
  for (;; ) {
17467
17468
  signal?.throwIfAborted();
@@ -17471,7 +17472,9 @@ class SqlResourceAdmission {
17471
17472
  }
17472
17473
  const hostReason = this.hostReason();
17473
17474
  const pressure = this.stageStartPressure(kind, request?.delegated);
17475
+ let quarantined = undefined;
17474
17476
  const granted = await this.storage.transaction(async (tx) => {
17477
+ quarantined = undefined;
17475
17478
  await tx.run(`UPDATE ${this.owners} SET expires_at = CAST(unixepoch('subsec') * 1000 AS INTEGER) + ? WHERE id = ?`, OWNER_LEASE_MS, this.owner);
17476
17479
  const { root } = this.options;
17477
17480
  if (!await this.assertCurrentAttempt(tx)) {
@@ -17489,6 +17492,8 @@ class SqlResourceAdmission {
17489
17492
  if (control && control.state !== "running")
17490
17493
  reason ??= `root ${control.state}`;
17491
17494
  }
17495
+ const held = [];
17496
+ let blockedOtherwise = reason !== undefined;
17492
17497
  for (const scope of [`root:${root}`, "user"]) {
17493
17498
  const row = await tx.get(`SELECT config FROM ${this.configuration} WHERE scope = ?`, scope);
17494
17499
  const limit = row ? JSON.parse(String(row.config))[kind] : undefined;
@@ -17498,9 +17503,21 @@ class SqlResourceAdmission {
17498
17503
  const args = scope === "user" ? [kind] : [kind, root];
17499
17504
  const count = await tx.get(`SELECT COUNT(*) AS count FROM ${this.permits} WHERE kind = ? ${rootClause} AND state = 'granted'`, ...args);
17500
17505
  const older = await tx.get(`SELECT 1 AS waiting FROM ${this.permits} WHERE kind = ? ${rootClause} AND state = 'waiting' AND reason IS NOT ? AND ticket < (SELECT ticket FROM ${this.permits} WHERE operation = ?) LIMIT 1`, ...args, STAGE_START_PRESSURE_REASON, operation);
17501
- if (Number(count?.count) >= limit || older)
17502
- reason ??= `${scope} ${kind}`;
17506
+ const used = Number(count?.count);
17507
+ if (used < limit && !older)
17508
+ continue;
17509
+ reason ??= `${scope} ${kind}`;
17510
+ const quarantinedPermits = kind === "process" && used >= limit ? await tx.all(`SELECT process_identity FROM ${this.permits} WHERE kind = ? ${rootClause} AND state = 'granted' AND reason = ?`, ...args, QUARANTINED_REASON) : [];
17511
+ if (quarantinedPermits.length > 0 && used - quarantinedPermits.length < limit)
17512
+ held.push({
17513
+ limit: `${scope === "user" ? "admission.userLimits" : "admission.limits"}.${kind} (${limit})`,
17514
+ groups: quarantinedPermits.flatMap(({ process_identity }) => process_identity === null ? [] : [JSON.parse(process_identity).id])
17515
+ });
17516
+ else
17517
+ blockedOtherwise = true;
17503
17518
  }
17519
+ if (!blockedOtherwise && held.length > 0)
17520
+ quarantined = held;
17504
17521
  if (reason && request?.delegated)
17505
17522
  throw new Error(`Delegated session cannot be admitted without capacity headroom: ${reason}`);
17506
17523
  const stageStart = kind === "session" && request?.delegated !== true;
@@ -17513,6 +17530,14 @@ class SqlResourceAdmission {
17513
17530
  signal?.throwIfAborted();
17514
17531
  return release;
17515
17532
  }
17533
+ if (quarantined === undefined)
17534
+ quarantinedSince = undefined;
17535
+ else {
17536
+ const now = performance.now();
17537
+ quarantinedSince ??= now;
17538
+ if (now - quarantinedSince >= QUARANTINED_CAPACITY_WAIT_MS)
17539
+ throw new QuarantinedCapacityError(quarantined, now - quarantinedSince);
17540
+ }
17516
17541
  await delay(50 + Math.floor(Math.random() * 25), undefined, { signal });
17517
17542
  }
17518
17543
  } catch (error) {
@@ -17554,9 +17579,9 @@ class SqlResourceAdmission {
17554
17579
  await tx.run(`UPDATE ${this.permits} SET state = 'released', reason = 'host reboot confirmed' WHERE owner = ? AND state = 'granted'`, owner.id);
17555
17580
  } else if (stopped) {
17556
17581
  await tx.run(`UPDATE ${this.permits} SET state = 'released', reason = 'owner death confirmed' WHERE owner = ? AND kind = 'session' AND state = 'granted'`, owner.id);
17557
- await tx.run(`UPDATE ${this.permits} SET reason = ? WHERE owner = ? AND kind = 'process' AND state = 'granted' AND reason IS NOT ?`, UNCONFIRMED_REASON, owner.id, UNCONFIRMED_REASON);
17582
+ await tx.run(`UPDATE ${this.permits} SET reason = ? WHERE owner = ? AND kind = 'process' AND state = 'granted' AND reason IS NOT ? AND reason IS NOT ?`, UNCONFIRMED_REASON, owner.id, UNCONFIRMED_REASON, QUARANTINED_REASON);
17558
17583
  } else {
17559
- await tx.run(`UPDATE ${this.permits} SET reason = ? WHERE owner = ? AND state = 'granted' AND reason IS NOT ? AND EXISTS (SELECT 1 FROM ${this.owners} WHERE id = ? AND expires_at < ${NOW_MS})`, EXPIRED_REASON, owner.id, EXPIRED_REASON, owner.id);
17584
+ await tx.run(`UPDATE ${this.permits} SET reason = ? WHERE owner = ? AND state = 'granted' AND reason IS NOT ? AND reason IS NOT ? AND EXISTS (SELECT 1 FROM ${this.owners} WHERE id = ? AND expires_at < ${NOW_MS})`, EXPIRED_REASON, owner.id, EXPIRED_REASON, QUARANTINED_REASON, owner.id);
17560
17585
  }
17561
17586
  if (owner.host === admissionHost && process.platform !== "win32") {
17562
17587
  const processes = await tx.all(`SELECT * FROM ${this.permits} WHERE owner = ? AND state = 'granted' AND kind = 'process' AND process_identity IS NOT NULL`, owner.id);
@@ -17584,9 +17609,10 @@ class SqlResourceAdmission {
17584
17609
  return true;
17585
17610
  if (permit.state === "waiting")
17586
17611
  return stopped || !!permit.expired;
17587
- if (stopped && (permit.kind === "session" || permit.reason !== UNCONFIRMED_REASON))
17612
+ const quarantined = permit.reason === QUARANTINED_REASON;
17613
+ if (stopped && (permit.kind === "session" || permit.reason !== UNCONFIRMED_REASON && !quarantined))
17588
17614
  return true;
17589
- if (!stopped && permit.expired && permit.reason !== EXPIRED_REASON)
17615
+ if (!stopped && permit.expired && permit.reason !== EXPIRED_REASON && !quarantined)
17590
17616
  return true;
17591
17617
  return permit.kind === "process" && permit.host === admissionHost && process.platform !== "win32" && permit.process_identity !== null && processGroupStopped(permit.process_identity);
17592
17618
  }
@@ -17597,7 +17623,9 @@ class SqlResourceAdmission {
17597
17623
  await this.reconcile();
17598
17624
  return this.storage.transaction(async (tx) => {
17599
17625
  await tx.run(`UPDATE ${this.permits} SET state = 'released', reason = 'worker exit confirmed' WHERE root = ? AND holder = ? AND state IN ('waiting', 'granted') AND (kind = 'session' OR state = 'waiting')`, this.options.root, holder);
17600
- return (await tx.run(`UPDATE ${this.permits} SET reason = 'cleanup unconfirmed' WHERE root = ? AND holder = ? AND kind = 'process' AND state = 'granted'`, this.options.root, holder)).changes;
17626
+ await tx.run(`UPDATE ${this.permits} SET reason = ? WHERE root = ? AND holder = ? AND kind = 'process' AND state = 'granted' AND reason IS NOT ?`, UNCONFIRMED_REASON, this.options.root, holder, QUARANTINED_REASON);
17627
+ const held = await tx.get(`SELECT COUNT(*) AS count FROM ${this.permits} WHERE root = ? AND holder = ? AND kind = 'process' AND state = 'granted'`, this.options.root, holder);
17628
+ return Number(held?.count);
17601
17629
  });
17602
17630
  }
17603
17631
  async reapStoppedProcesses(holder) {
@@ -17644,10 +17672,13 @@ async function settleProcessGroups(storage, root, scope, reap) {
17644
17672
  survivors = await settle(REAP_KILL_GRACE_MS);
17645
17673
  }
17646
17674
  const confirmed = [...groups].flatMap(([group, operations]) => survivors.includes(group) ? [] : operations);
17647
- if (confirmed.length > 0)
17675
+ const quarantined = reap ? survivors.flatMap((group) => groups.get(group) ?? []) : [];
17676
+ if (confirmed.length > 0 || quarantined.length > 0)
17648
17677
  await storage.transaction(async (tx) => {
17649
17678
  for (const operation of confirmed)
17650
17679
  await tx.run(`UPDATE ${permits} SET state = 'released', reason = 'process group cleanup confirmed' WHERE operation = ? AND state = 'granted'`, operation);
17680
+ for (const operation of quarantined)
17681
+ await tx.run(`UPDATE ${permits} SET reason = ? WHERE operation = ? AND state = 'granted'`, QUARANTINED_REASON, operation);
17651
17682
  });
17652
17683
  return { survivors, unprobeable };
17653
17684
  }
@@ -17664,11 +17695,20 @@ async function reapProcessGroups(journal, root, scope) {
17664
17695
  return { survivors: [], unprobeable: [] };
17665
17696
  return settleProcessGroups(storage, root, scope, true);
17666
17697
  }
17667
- var OWNER_LEASE_MS = 30000, REAP_TERM_GRACE_MS = 5000, REAP_KILL_GRACE_MS = 1e4, REAP_POLL_MS = 50, RELEASED_PERMIT_RETENTION = 256, RELEASED_PERMIT_PRUNE_BATCH = 1000, EXPIRED_REASON = "owner expired; cleanup unconfirmed", UNCONFIRMED_REASON = "cleanup unconfirmed", ANONYMOUS_REASON = "owner identity unavailable; cleanup unconfirmed", NOW_MS = "CAST(unixepoch('subsec') * 1000 AS INTEGER)", LATE_ATTEMPT_POLL_MS = 100, LATE_ATTEMPT_REASON = "waiting for its attempt lease", STAGE_START_SPACING_MS = 5000, STAGE_START_PRESSURE_REASON = "paced for host load";
17698
+ var OWNER_LEASE_MS = 30000, REAP_TERM_GRACE_MS = 5000, REAP_KILL_GRACE_MS = 1e4, REAP_POLL_MS = 50, RELEASED_PERMIT_RETENTION = 256, RELEASED_PERMIT_PRUNE_BATCH = 1000, EXPIRED_REASON = "owner expired; cleanup unconfirmed", UNCONFIRMED_REASON = "cleanup unconfirmed", QUARANTINED_REASON = "cleanup unconfirmed; permit quarantined", ANONYMOUS_REASON = "owner identity unavailable; cleanup unconfirmed", QUARANTINED_CAPACITY_WAIT_MS, QuarantinedCapacityError, NOW_MS = "CAST(unixepoch('subsec') * 1000 AS INTEGER)", LATE_ATTEMPT_POLL_MS = 100, LATE_ATTEMPT_REASON = "waiting for its attempt lease", STAGE_START_SPACING_MS = 5000, STAGE_START_PRESSURE_REASON = "paced for host load";
17668
17699
  var init_resource_admission = __esm(() => {
17669
17700
  init_internal();
17670
17701
  init_sql_run_journal();
17702
+ init_workflow_infrastructure_error();
17671
17703
  init_admission_host();
17704
+ QUARANTINED_CAPACITY_WAIT_MS = REAP_TERM_GRACE_MS + REAP_KILL_GRACE_MS;
17705
+ QuarantinedCapacityError = class QuarantinedCapacityError extends WorkflowInfrastructureError {
17706
+ constructor(capacity, waitedMs) {
17707
+ const limits = capacity.map(({ limit }) => limit).join(" and ");
17708
+ const groups = [...new Set(capacity.flatMap(({ groups }) => groups))].join(", ");
17709
+ super("worker_cleanup_unconfirmed", `Local worker process cleanup unconfirmed; process permit refused after ${Math.round(waitedMs)} ms: ${limits} ${capacity.length === 1 ? "is" : "are"} held by quarantined permits of process groups ${groups} that survived SIGKILL`);
17710
+ }
17711
+ };
17672
17712
  });
17673
17713
 
17674
17714
  // dist/builtin/workflows/src/workers/admission-binding.ts
@@ -81600,6 +81640,30 @@ class WorkAttemptFencedError extends Error {
81600
81640
  this.name = "WorkAttemptFencedError";
81601
81641
  }
81602
81642
  }
81643
+ var FENCED_STAGE_CLOSE_MS = 1e4;
81644
+ async function closeAttemptStages(stageControls, store, renew) {
81645
+ const closing = Promise.allSettled(store.runs().map((run) => stageControls.clear(run.id))).then((results) => {
81646
+ for (const result of results)
81647
+ if (result.status === "rejected")
81648
+ console.warn("atomic-workflows: a fenced attempt's stage session did not close", result.reason);
81649
+ });
81650
+ let renewal;
81651
+ const pulse = () => {
81652
+ renewal ??= renew().then(() => {}, () => {}).finally(() => {
81653
+ renewal = undefined;
81654
+ });
81655
+ };
81656
+ pulse();
81657
+ const heartbeat = setInterval(pulse, WORKER_HEARTBEAT_INTERVAL_MS);
81658
+ const bound = new AbortController;
81659
+ try {
81660
+ await Promise.race([closing, delay4(FENCED_STAGE_CLOSE_MS, undefined, { signal: bound.signal }).catch(() => {})]);
81661
+ } finally {
81662
+ clearInterval(heartbeat);
81663
+ bound.abort();
81664
+ await renewal;
81665
+ }
81666
+ }
81603
81667
  function trackTurnStarts(registry, started) {
81604
81668
  return new Proxy(registry, {
81605
81669
  get(target, key) {
@@ -81760,7 +81824,7 @@ async function runWorker(start) {
81760
81824
  }
81761
81825
  return state;
81762
81826
  };
81763
- const backend = new Proxy(reader, {
81827
+ const attemptBackend = (write) => new Proxy(reader, {
81764
81828
  get(target, key) {
81765
81829
  if (typeof key !== "string" || key === "then" || /^(archive|readSettled|readPending|hasCached|hasPending|getPending)/.test(key))
81766
81830
  return;
@@ -81779,7 +81843,7 @@ async function runWorker(start) {
81779
81843
  if (key === "commitCheckpoint" || key === "commitAdditiveCheckpointBestEffort")
81780
81844
  return async (checkpoint, options) => {
81781
81845
  options?.signal?.throwIfAborted();
81782
- await commit({ kind: "checkpoint", checkpoint });
81846
+ await write({ kind: "checkpoint", checkpoint });
81783
81847
  return true;
81784
81848
  };
81785
81849
  if (typeof key === "string" && reads.has(key)) {
@@ -81850,6 +81914,9 @@ async function runWorker(start) {
81850
81914
  activeController = controller;
81851
81915
  heldAttempt = work;
81852
81916
  const store = createStore();
81917
+ let writable = true;
81918
+ const write = (mutation) => writable ? commit(mutation) : Promise.reject(new WorkAttemptFencedError("Work attempt ended"));
81919
+ const backend = attemptBackend(write);
81853
81920
  currentStore = store;
81854
81921
  const stageUiBroker = new WorkerStageUiBroker(store);
81855
81922
  prompts = { store, broker: stageUiBroker, fail: failablePrompts(store) };
@@ -81929,7 +81996,18 @@ async function runWorker(start) {
81929
81996
  const resourceAdmission = {
81930
81997
  async acquire(kind, signal, requestOptions) {
81931
81998
  await awaitAdmission(signal);
81932
- return admission ? admission.acquire(kind, signal, requestOptions) : async () => {};
81999
+ if (!admission)
82000
+ return async () => {};
82001
+ try {
82002
+ return await admission.acquire(kind, signal, requestOptions);
82003
+ } catch (error) {
82004
+ if (error instanceof QuarantinedCapacityError && !attemptFenced) {
82005
+ poolFault = error.message;
82006
+ attemptFenced = true;
82007
+ controller.abort(new WorkAttemptFencedError(error.message));
82008
+ }
82009
+ throw error;
82010
+ }
81933
82011
  }
81934
82012
  };
81935
82013
  const admitted = admittedSessionAdapter(adapter, resourceAdmission, async (signal) => {
@@ -81985,7 +82063,7 @@ async function runWorker(start) {
81985
82063
  backend: reader,
81986
82064
  store,
81987
82065
  root: start.root,
81988
- commit
82066
+ commit: write
81989
82067
  })
81990
82068
  }
81991
82069
  }));
@@ -82025,6 +82103,8 @@ async function runWorker(start) {
82025
82103
  await renewal;
82026
82104
  await report;
82027
82105
  await controlPulse;
82106
+ await closeAttemptStages(stageControls, store, () => request({ kind: "renew" }));
82107
+ writable = false;
82028
82108
  await request({ kind: "fenced", ...poolFault === undefined ? {} : { fault: poolFault } });
82029
82109
  }
82030
82110
  if (rootLeaseLost)
@@ -17573,6 +17573,7 @@ class SqlResourceAdmission {
17573
17573
  epoch: this.options.attempt.epoch
17574
17574
  }) : null);
17575
17575
  });
17576
+ let quarantinedSince;
17576
17577
  try {
17577
17578
  for (;; ) {
17578
17579
  signal?.throwIfAborted();
@@ -17582,7 +17583,9 @@ class SqlResourceAdmission {
17582
17583
  }
17583
17584
  const hostReason = this.hostReason();
17584
17585
  const pressure = this.stageStartPressure(kind, request?.delegated);
17586
+ let quarantined = undefined;
17585
17587
  const granted = await this.storage.transaction(async (tx) => {
17588
+ quarantined = undefined;
17586
17589
  await tx.run(`UPDATE ${this.owners} SET expires_at = CAST(unixepoch('subsec') * 1000 AS INTEGER) + ? WHERE id = ?`, OWNER_LEASE_MS, this.owner);
17587
17590
  const { root } = this.options;
17588
17591
  if (!await this.assertCurrentAttempt(tx)) {
@@ -17600,6 +17603,8 @@ class SqlResourceAdmission {
17600
17603
  if (control && control.state !== "running")
17601
17604
  reason ??= `root ${control.state}`;
17602
17605
  }
17606
+ const held = [];
17607
+ let blockedOtherwise = reason !== undefined;
17603
17608
  for (const scope of [`root:${root}`, "user"]) {
17604
17609
  const row = await tx.get(`SELECT config FROM ${this.configuration} WHERE scope = ?`, scope);
17605
17610
  const limit = row ? JSON.parse(String(row.config))[kind] : undefined;
@@ -17609,9 +17614,21 @@ class SqlResourceAdmission {
17609
17614
  const args = scope === "user" ? [kind] : [kind, root];
17610
17615
  const count = await tx.get(`SELECT COUNT(*) AS count FROM ${this.permits} WHERE kind = ? ${rootClause} AND state = 'granted'`, ...args);
17611
17616
  const older = await tx.get(`SELECT 1 AS waiting FROM ${this.permits} WHERE kind = ? ${rootClause} AND state = 'waiting' AND reason IS NOT ? AND ticket < (SELECT ticket FROM ${this.permits} WHERE operation = ?) LIMIT 1`, ...args, STAGE_START_PRESSURE_REASON, operation);
17612
- if (Number(count?.count) >= limit || older)
17613
- reason ??= `${scope} ${kind}`;
17617
+ const used = Number(count?.count);
17618
+ if (used < limit && !older)
17619
+ continue;
17620
+ reason ??= `${scope} ${kind}`;
17621
+ const quarantinedPermits = kind === "process" && used >= limit ? await tx.all(`SELECT process_identity FROM ${this.permits} WHERE kind = ? ${rootClause} AND state = 'granted' AND reason = ?`, ...args, QUARANTINED_REASON) : [];
17622
+ if (quarantinedPermits.length > 0 && used - quarantinedPermits.length < limit)
17623
+ held.push({
17624
+ limit: `${scope === "user" ? "admission.userLimits" : "admission.limits"}.${kind} (${limit})`,
17625
+ groups: quarantinedPermits.flatMap(({ process_identity }) => process_identity === null ? [] : [JSON.parse(process_identity).id])
17626
+ });
17627
+ else
17628
+ blockedOtherwise = true;
17614
17629
  }
17630
+ if (!blockedOtherwise && held.length > 0)
17631
+ quarantined = held;
17615
17632
  if (reason && request?.delegated)
17616
17633
  throw new Error(`Delegated session cannot be admitted without capacity headroom: ${reason}`);
17617
17634
  const stageStart = kind === "session" && request?.delegated !== true;
@@ -17624,6 +17641,14 @@ class SqlResourceAdmission {
17624
17641
  signal?.throwIfAborted();
17625
17642
  return release;
17626
17643
  }
17644
+ if (quarantined === undefined)
17645
+ quarantinedSince = undefined;
17646
+ else {
17647
+ const now = performance.now();
17648
+ quarantinedSince ??= now;
17649
+ if (now - quarantinedSince >= QUARANTINED_CAPACITY_WAIT_MS)
17650
+ throw new QuarantinedCapacityError(quarantined, now - quarantinedSince);
17651
+ }
17627
17652
  await delay(50 + Math.floor(Math.random() * 25), undefined, { signal });
17628
17653
  }
17629
17654
  } catch (error) {
@@ -17665,9 +17690,9 @@ class SqlResourceAdmission {
17665
17690
  await tx.run(`UPDATE ${this.permits} SET state = 'released', reason = 'host reboot confirmed' WHERE owner = ? AND state = 'granted'`, owner.id);
17666
17691
  } else if (stopped) {
17667
17692
  await tx.run(`UPDATE ${this.permits} SET state = 'released', reason = 'owner death confirmed' WHERE owner = ? AND kind = 'session' AND state = 'granted'`, owner.id);
17668
- await tx.run(`UPDATE ${this.permits} SET reason = ? WHERE owner = ? AND kind = 'process' AND state = 'granted' AND reason IS NOT ?`, UNCONFIRMED_REASON, owner.id, UNCONFIRMED_REASON);
17693
+ await tx.run(`UPDATE ${this.permits} SET reason = ? WHERE owner = ? AND kind = 'process' AND state = 'granted' AND reason IS NOT ? AND reason IS NOT ?`, UNCONFIRMED_REASON, owner.id, UNCONFIRMED_REASON, QUARANTINED_REASON);
17669
17694
  } else {
17670
- await tx.run(`UPDATE ${this.permits} SET reason = ? WHERE owner = ? AND state = 'granted' AND reason IS NOT ? AND EXISTS (SELECT 1 FROM ${this.owners} WHERE id = ? AND expires_at < ${NOW_MS})`, EXPIRED_REASON, owner.id, EXPIRED_REASON, owner.id);
17695
+ await tx.run(`UPDATE ${this.permits} SET reason = ? WHERE owner = ? AND state = 'granted' AND reason IS NOT ? AND reason IS NOT ? AND EXISTS (SELECT 1 FROM ${this.owners} WHERE id = ? AND expires_at < ${NOW_MS})`, EXPIRED_REASON, owner.id, EXPIRED_REASON, QUARANTINED_REASON, owner.id);
17671
17696
  }
17672
17697
  if (owner.host === admissionHost && process.platform !== "win32") {
17673
17698
  const processes = await tx.all(`SELECT * FROM ${this.permits} WHERE owner = ? AND state = 'granted' AND kind = 'process' AND process_identity IS NOT NULL`, owner.id);
@@ -17695,9 +17720,10 @@ class SqlResourceAdmission {
17695
17720
  return true;
17696
17721
  if (permit.state === "waiting")
17697
17722
  return stopped || !!permit.expired;
17698
- if (stopped && (permit.kind === "session" || permit.reason !== UNCONFIRMED_REASON))
17723
+ const quarantined = permit.reason === QUARANTINED_REASON;
17724
+ if (stopped && (permit.kind === "session" || permit.reason !== UNCONFIRMED_REASON && !quarantined))
17699
17725
  return true;
17700
- if (!stopped && permit.expired && permit.reason !== EXPIRED_REASON)
17726
+ if (!stopped && permit.expired && permit.reason !== EXPIRED_REASON && !quarantined)
17701
17727
  return true;
17702
17728
  return permit.kind === "process" && permit.host === admissionHost && process.platform !== "win32" && permit.process_identity !== null && processGroupStopped(permit.process_identity);
17703
17729
  }
@@ -17708,7 +17734,9 @@ class SqlResourceAdmission {
17708
17734
  await this.reconcile();
17709
17735
  return this.storage.transaction(async (tx) => {
17710
17736
  await tx.run(`UPDATE ${this.permits} SET state = 'released', reason = 'worker exit confirmed' WHERE root = ? AND holder = ? AND state IN ('waiting', 'granted') AND (kind = 'session' OR state = 'waiting')`, this.options.root, holder);
17711
- return (await tx.run(`UPDATE ${this.permits} SET reason = 'cleanup unconfirmed' WHERE root = ? AND holder = ? AND kind = 'process' AND state = 'granted'`, this.options.root, holder)).changes;
17737
+ await tx.run(`UPDATE ${this.permits} SET reason = ? WHERE root = ? AND holder = ? AND kind = 'process' AND state = 'granted' AND reason IS NOT ?`, UNCONFIRMED_REASON, this.options.root, holder, QUARANTINED_REASON);
17738
+ const held = await tx.get(`SELECT COUNT(*) AS count FROM ${this.permits} WHERE root = ? AND holder = ? AND kind = 'process' AND state = 'granted'`, this.options.root, holder);
17739
+ return Number(held?.count);
17712
17740
  });
17713
17741
  }
17714
17742
  async reapStoppedProcesses(holder) {
@@ -17755,10 +17783,13 @@ async function settleProcessGroups(storage, root, scope, reap) {
17755
17783
  survivors = await settle(REAP_KILL_GRACE_MS);
17756
17784
  }
17757
17785
  const confirmed = [...groups].flatMap(([group, operations]) => survivors.includes(group) ? [] : operations);
17758
- if (confirmed.length > 0)
17786
+ const quarantined = reap ? survivors.flatMap((group) => groups.get(group) ?? []) : [];
17787
+ if (confirmed.length > 0 || quarantined.length > 0)
17759
17788
  await storage.transaction(async (tx) => {
17760
17789
  for (const operation of confirmed)
17761
17790
  await tx.run(`UPDATE ${permits} SET state = 'released', reason = 'process group cleanup confirmed' WHERE operation = ? AND state = 'granted'`, operation);
17791
+ for (const operation of quarantined)
17792
+ await tx.run(`UPDATE ${permits} SET reason = ? WHERE operation = ? AND state = 'granted'`, QUARANTINED_REASON, operation);
17762
17793
  });
17763
17794
  return { survivors, unprobeable };
17764
17795
  }
@@ -17775,11 +17806,20 @@ async function reapProcessGroups(journal, root, scope) {
17775
17806
  return { survivors: [], unprobeable: [] };
17776
17807
  return settleProcessGroups(storage, root, scope, true);
17777
17808
  }
17778
- var OWNER_LEASE_MS = 30000, REAP_TERM_GRACE_MS = 5000, REAP_KILL_GRACE_MS = 1e4, REAP_POLL_MS = 50, RELEASED_PERMIT_RETENTION = 256, RELEASED_PERMIT_PRUNE_BATCH = 1000, EXPIRED_REASON = "owner expired; cleanup unconfirmed", UNCONFIRMED_REASON = "cleanup unconfirmed", ANONYMOUS_REASON = "owner identity unavailable; cleanup unconfirmed", NOW_MS = "CAST(unixepoch('subsec') * 1000 AS INTEGER)", LATE_ATTEMPT_POLL_MS = 100, LATE_ATTEMPT_REASON = "waiting for its attempt lease", STAGE_START_SPACING_MS = 5000, STAGE_START_PRESSURE_REASON = "paced for host load";
17809
+ var OWNER_LEASE_MS = 30000, REAP_TERM_GRACE_MS = 5000, REAP_KILL_GRACE_MS = 1e4, REAP_POLL_MS = 50, RELEASED_PERMIT_RETENTION = 256, RELEASED_PERMIT_PRUNE_BATCH = 1000, EXPIRED_REASON = "owner expired; cleanup unconfirmed", UNCONFIRMED_REASON = "cleanup unconfirmed", QUARANTINED_REASON = "cleanup unconfirmed; permit quarantined", ANONYMOUS_REASON = "owner identity unavailable; cleanup unconfirmed", QUARANTINED_CAPACITY_WAIT_MS, QuarantinedCapacityError, NOW_MS = "CAST(unixepoch('subsec') * 1000 AS INTEGER)", LATE_ATTEMPT_POLL_MS = 100, LATE_ATTEMPT_REASON = "waiting for its attempt lease", STAGE_START_SPACING_MS = 5000, STAGE_START_PRESSURE_REASON = "paced for host load";
17779
17810
  var init_resource_admission = __esm(() => {
17780
17811
  init_internal();
17781
17812
  init_sql_run_journal();
17813
+ init_workflow_infrastructure_error();
17782
17814
  init_admission_host();
17815
+ QUARANTINED_CAPACITY_WAIT_MS = REAP_TERM_GRACE_MS + REAP_KILL_GRACE_MS;
17816
+ QuarantinedCapacityError = class QuarantinedCapacityError extends WorkflowInfrastructureError {
17817
+ constructor(capacity, waitedMs) {
17818
+ const limits = capacity.map(({ limit }) => limit).join(" and ");
17819
+ const groups = [...new Set(capacity.flatMap(({ groups }) => groups))].join(", ");
17820
+ super("worker_cleanup_unconfirmed", `Local worker process cleanup unconfirmed; process permit refused after ${Math.round(waitedMs)} ms: ${limits} ${capacity.length === 1 ? "is" : "are"} held by quarantined permits of process groups ${groups} that survived SIGKILL`);
17821
+ }
17822
+ };
17783
17823
  });
17784
17824
 
17785
17825
  // dist/builtin/workflows/src/workers/admission-binding.ts