pi-durable-subagents 1.0.10 → 1.0.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.11
4
+
5
+ - A gate whose processes outlive their fence is reported once: when the gate
6
+ and the background sweep both recorded the failure at the same moment, a
7
+ workflow could show the same "processes may still run" attention item twice
8
+ (and the failure, the gate's unknown outcome and the resolution likewise).
9
+
3
10
  ## 1.0.10
4
11
 
5
12
  - Provider failover for a used-up usage window. An error such as `503 No
@@ -5,15 +5,17 @@ import { join } from "node:path";
5
5
  import { publishFile } from "../../../kernel/mailbox.js";
6
6
  import { callDir } from "../../../paths.js";
7
7
  import { validate } from "../../../agent/child/schema.js";
8
- import { recordFenceFailure, resolveFenceAttention } from "../sweep.js";
8
+ import { fenceAttentionResolved, recordFenceFailure, recordOnce } from "../sweep.js";
9
9
  const tracked = (journal, id) => journal.entries().filter(e => e.type === "gate-tracked" && e.id === id).map(e => e.process);
10
10
  // Calls parked on an unfenced gate, woken when the sweep records the gate's outcome.
11
11
  const parked = new Map();
12
12
  /** P30, F1: A gate proven retired after a failed fence gets its unknown outcome once and its attention resolved. */
13
13
  export async function gateRetired(journal, id) {
14
- if (!journal.entries().some(e => e.type === "gate" && e.id === id))
15
- await journal.append("gate", { id, unknown: true });
16
- await resolveFenceAttention(journal, id);
14
+ await recordOnce(journal, async () => {
15
+ if (!journal.entries().some(e => e.type === "gate" && e.id === id))
16
+ await journal.append("gate", { id, unknown: true });
17
+ await fenceAttentionResolved(journal, id);
18
+ });
17
19
  for (const wake of parked.get(id) ?? [])
18
20
  wake();
19
21
  }
@@ -52,8 +54,9 @@ async function fenceGate(journal, intent, containment) {
52
54
  console.error(`durable-subagents: fence of ${id} failed: ${String(error)}`);
53
55
  if (/Fence timeout/.test(String(error)))
54
56
  await recordFenceFailure(journal, id, String(intent.call), error);
55
- else if (!journal.entries().some(e => e.type === "fence-failed" && e.exec === id))
56
- await journal.append("fence-failed", { exec: id, error: String(error) });
57
+ else
58
+ await recordOnce(journal, async () => { if (!journal.entries().some(e => e.type === "fence-failed" && e.exec === id))
59
+ await journal.append("fence-failed", { exec: id, error: String(error) }); });
57
60
  return false;
58
61
  }
59
62
  }
@@ -21,8 +21,23 @@ export async function skipLostCandidate(journal, orch, exec) {
21
21
  if (tail.length === 3 && tail.every(e => e.type === "candidate-loss" && e.model === name))
22
22
  await orch.append("skip", { pool: selected.pool, model: name, until: Date.now() + 600000 });
23
23
  }
24
+ // The gate path records outside the executor's serial section and the sweep inside it: each check-then-append of a
25
+ // fence record runs in this per-journal section, so two recorders that both find no record still write it once (A5).
26
+ const sections = new WeakMap();
27
+ /** A5: Run a check-then-append of fence records alone on its journal. Not reentrant: never nest two. */
28
+ export function recordOnce(journal, operation) {
29
+ const result = (sections.get(journal) ?? Promise.resolve()).then(operation);
30
+ const tail = result.catch(() => { });
31
+ sections.set(journal, tail);
32
+ void tail.then(() => { if (sections.get(journal) === tail)
33
+ sections.delete(journal); });
34
+ return result;
35
+ }
24
36
  /** F1: Record a fence timeout of an execution or gate identity once, with one unknown attention item for the origin. */
25
- export async function recordFenceFailure(journal, id, call, error) {
37
+ export function recordFenceFailure(journal, id, call, error) {
38
+ return recordOnce(journal, () => fenceFailure(journal, id, call, error));
39
+ }
40
+ async function fenceFailure(journal, id, call, error) {
26
41
  if (!journal.entries().some(e => e.type === "fence-failed" && e.exec === id))
27
42
  await journal.append("fence-failed", { exec: id, error: String(error) });
28
43
  const item = `fence:${id}`;
@@ -34,7 +49,11 @@ export async function recordFenceFailure(journal, id, call, error) {
34
49
  await journal.append(JT.attention, { item: { id: item, rev: 1, kind: "unknown", text, wid: call.slice(0, call.lastIndexOf("@", call.indexOf("/"))), call } });
35
50
  }
36
51
  /** F1: Resolve the fence attention item of an identity that a later fence proved retired. */
37
- export async function resolveFenceAttention(journal, id) {
52
+ export function resolveFenceAttention(journal, id) {
53
+ return recordOnce(journal, () => fenceAttentionResolved(journal, id));
54
+ }
55
+ /** The same, inside a `recordOnce` section. */
56
+ export async function fenceAttentionResolved(journal, id) {
38
57
  const item = journal.entries().find(e => e.type === JT.attention && e.item.id === `fence:${id}`)?.item;
39
58
  if (item && !journal.entries().some(e => e.type === JT.attentionResolved && e.id === `fence:${id}` && e.rev === item.rev))
40
59
  await journal.append(JT.attentionResolved, { id: `fence:${id}`, rev: item.rev, resolution: "fenced" });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-durable-subagents",
3
- "version": "1.0.10",
3
+ "version": "1.0.11",
4
4
  "description": "Subagents for pi that never lose work and never do it twice. Crash-safe workflows, automatic recovery, and a live view just like the main agent.",
5
5
  "type": "module",
6
6
  "license": "MIT",