pi-durable-subagents 1.0.25 → 1.0.27

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,37 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.27
4
+
5
+ - `hold --no-wait` (or `--max-wait 0`) takes the lease at once or exits 75
6
+ without ever being queued: the decision is made under the resource's lock
7
+ and a refused request writes nothing, so `leases`, `status` and other
8
+ waiters never see it. Before, `--max-wait 0` queued a ticket, checked, and
9
+ removed it. A no-wait request also no longer ends processes left by a
10
+ killed holder (a queued waiter still does); it is refused while they remain.
11
+ - Release check: `pack:smoke` waits for its orchestrator to exit before
12
+ removing its temporary directory, and a failed removal is a warning. The
13
+ 1.0.26 release stopped at this step on macOS (`ENOTEMPTY` after
14
+ `pack-smoke ok`), so 1.0.26 was never published; 1.0.27 includes its
15
+ changes.
16
+
17
+ ## 1.0.26
18
+
19
+ - A daily quota message in Chinese ("remaining quota is 0, resets at 00:00 the
20
+ next day") is a used-up usage window: a pool call moves to its next candidate
21
+ and a single-model call waits, instead of failing at once as a balance error
22
+ (the word for "balance" occurs inside "remaining quota"). Checked against the 7,941 provider errors
23
+ recorded on a working machine: this was the only usage-window text misread,
24
+ and no rate limit or transient error is read as one.
25
+ - `answered.by` is `call:<wid>/<key>` for a subagent answering through the CLI
26
+ (the CLI now sends its `DSA_CALL` as `caller`). The caller is provenance and
27
+ not part of `spec_digest`: retrying a request id with or without it is the
28
+ same request. A pi session still running an older extension computes the
29
+ digest with the caller included and would see such a retry as a conflict.
30
+ - Tests: an end-to-end pool failover through the CLI and a detached
31
+ orchestrator; the effects fixtures use a unique workflow id, since gate
32
+ processes are found by tag across the whole machine and parallel test files
33
+ shared one; the CI runner also reruns files reported in nested tests.
34
+
3
35
  ## 1.0.25
4
36
 
5
37
  - `restart` refusals show what a fence would cut short: each lease a running
package/README.md CHANGED
@@ -55,7 +55,7 @@ the npx cache, so `install-service` refuses to run from there.
55
55
  | You steer a subagent while it is asking you a question | Your message reaches it, in order. Nothing is rejected or lost. |
56
56
  | Two steers arrive out of order and the second replaces the first | Only the second one applies. |
57
57
  | A step is refused, or a dependency fails | The workflow stops that branch cleanly. Nothing is retried in vain. |
58
- | A provider's usage window runs out (`No available accounts`, usage limit, quota exceeded) | Found at the second refusal in a row, while pi is still retrying. A call in a pool continues **in the same session** on the pool's next model (within pi's next retry or two); new calls skip that provider. After 15 minutes the next call that wants it tries it once; when it answers, new calls and new generations use it again. A call with a single model waits for it instead of failing. Billing errors (402, insufficient balance) still fail at once. |
58
+ | A provider's usage window runs out (`No available accounts`, usage limit, quota exceeded, a daily quota at 0) | Found at the second refusal in a row, while pi is still retrying. A call in a pool continues **in the same session** on the pool's next model (within pi's next retry or two); new calls skip that provider. After 15 minutes the next call that wants it tries it once; when it answers, new calls and new generations use it again. A call with a single model waits for it instead of failing. Billing errors (402, insufficient balance) still fail at once. |
59
59
  | Two subagents would write in the same worktree | Only one runs there at a time. A call that can write (its tools include `edit` or `write`, which pi's default tools do) holds its git worktree's writer lock from its launch until it ends, also while it waits for an answer. Another writer for that worktree waits in order, and status shows `waiting for writer lock: <root> held by <wid>/<key>`. `writer: false` (a call that does not write there), `isolation: "worktree"` and `"writerLock": "off"` opt out. |
60
60
  | A subagent waits for an answer for a long time | It releases its model slot and memory, then resumes exactly once when you answer. The question survives orchestrator restarts (also forced ones) and crashes, including one that hits before the subagent released its slot. |
61
61
  | A subagent's work ends (finished, stopped, or cut off) | Every process its tools started ends with that execution, also ones started with `nohup`, `setsid` or `&`: they carry the execution's tag (see the limit below). Anything that must outlive the subagent has to be started by you or the parent session. A command run under `hold` is no exception: a forced restart stops it and its lease is released. |
@@ -254,7 +254,7 @@ pi-durable-subagents stop-all pause every existing workflow now; journ
254
254
  pi-durable-subagents prune [wid] [--older-than <days>]
255
255
  delete finished workflows (done, failed, stopped); prints count and bytes freed
256
256
  pi-durable-subagents restart [--force <token> --reason <text>] switch to the installed version (see "Updating Durable Subagents")
257
- pi-durable-subagents hold <resource> [--shared] [--max-wait <s>] [--note <text>] -- <command…>
257
+ pi-durable-subagents hold <resource> [--shared] [--max-wait <s> | --no-wait] [--note <text>] -- <command…>
258
258
  run one command while holding a resource lease (see below)
259
259
  pi-durable-subagents leases [--json] who holds and who waits for each resource
260
260
  pi-durable-subagents doctor [--json] read-only health check; exits 1 when something needs you
@@ -361,9 +361,9 @@ Every event has `id`, `cursor`, `ts` (when the milestone happened), `type`,
361
361
 
362
362
  Readers must ignore types they do not know (`waiting`/`moving` follow).
363
363
  `by` is the sender of the answer: `session:<id>` for a pi session (with
364
- `via: "ui"` when it came from the subagent list), `cli:<user>@<host>` for the
365
- CLI (a subagent answering through the CLI also shows as `cli:…`), else
366
- `unknown`. `fenced` is emitted when the call's next execution begins (right
364
+ `via: "ui"` when it came from the subagent list), `call:<wid>/<key>` for a
365
+ subagent answering through the CLI (its `DSA_CALL`; provenance, not
366
+ authority), `cli:<user>@<host>` for any other CLI use, else `unknown`. `fenced` is emitted when the call's next execution begins (right
367
367
  after recovery, before it waits for a slot) and only when the fence
368
368
  interrupted work, exactly as `describe`'s `lastFence`: a turn that had ended,
369
369
  a hibernated question, an answer's resume or a seal are no `fenced`. A `once`
@@ -473,6 +473,14 @@ pi-durable-subagents hold machine --max-wait 600 --note "profile" -- ./measure.s
473
473
  everything before it, and keeps later shared requests out (no starvation).
474
474
  A waiting `hold` prints who holds the resource; `--max-wait` gives up with
475
475
  exit 75 without running the command.
476
+ - `--no-wait` (same as `--max-wait 0`) takes the lease now or not at all.
477
+ It decides under the resource's lock. If it can run now, its request is
478
+ written already granted. Otherwise nothing is written, and it exits 75
479
+ naming who holds or waits, without running the command. It is never
480
+ listed as a waiter, even for a moment, so it can probe a resource whose
481
+ owner treats any queued request as interference. It also leaves the
482
+ resource alone: unlike a queued waiter, it does not end processes left
483
+ by a holder whose `hold` was killed, and is refused while they remain.
476
484
  - The command runs without a shell (write `-- sh -c '…'` for one) in its
477
485
  own process group; signals to `hold` go to it and its exit status is
478
486
  returned. When it exits, whatever it left in its process group is ended
@@ -514,7 +522,8 @@ State lives in `~/.pi/durable-subagents`; set `DSA_HOME` to move it.
514
522
  }
515
523
  ```
516
524
 
517
- - **Pools:** a model can name a pool. The first candidate with a free slot is
525
+ - **Pools:** a model can name a pool (the `model` of a call, a `run --spec`
526
+ file or a model send). The first candidate with a free slot is
518
527
  used, and a candidate that keeps failing is skipped for 10 minutes. The
519
528
  order is the preference: list the provider you want to use first.
520
529
  - **A used-up provider** is not sent new calls until its next try, 15 minutes
package/dist/cli/hold.js CHANGED
@@ -1,13 +1,14 @@
1
1
  // `pi-durable-subagents hold <resource> [--shared] [--max-wait <s>] [--note <text>] -- <command> [args…]`
2
2
  // Waits for the lease (strict FIFO), runs the command in its own process group, ends what the command left in that
3
- // group, then releases. See src/platform/lease.ts for the ticket protocol.
3
+ // group, then releases. `--max-wait 0` (or `--no-wait`) takes the lease at once or exits 75 without ever being queued.
4
+ // See src/platform/lease.ts for the ticket protocol.
4
5
  import { spawn } from "node:child_process";
5
6
  import { watch } from "node:fs";
6
7
  import { constants } from "node:os";
7
8
  import { dsaHome } from "../paths.js";
8
9
  import { captureStart } from "../platform/proctable.js";
9
- import { blockers, enqueue, groupAlive, leaseDir, liveTickets, orphaned, removeTicket, RESOURCE, who, writeTicket } from "../platform/lease.js";
10
- export const HOLD_USAGE = "usage: pi-durable-subagents hold <resource> [--shared] [--max-wait <seconds>] [--note <text>] -- <command> [args…]";
10
+ import { blockers, enqueue, groupAlive, tryGrant, leaseDir, liveTickets, orphaned, removeTicket, RESOURCE, who, writeTicket } from "../platform/lease.js";
11
+ export const HOLD_USAGE = "usage: pi-durable-subagents hold <resource> [--shared] [--max-wait <seconds> | --no-wait] [--note <text>] -- <command> [args…]";
11
12
  /** Exit status when --max-wait expires before the lease is granted (EX_TEMPFAIL). */
12
13
  export const WAIT_EXPIRED = 75;
13
14
  export function parseHold(args) {
@@ -20,6 +21,8 @@ export function parseHold(args) {
20
21
  const a = opts[i];
21
22
  if (a === "--shared")
22
23
  mode = "shared";
24
+ else if (a === "--no-wait")
25
+ maxWaitMs = 0;
23
26
  else if (a === "--max-wait" || a === "--note") {
24
27
  const v = opts[++i];
25
28
  if (v === undefined)
@@ -54,13 +57,21 @@ const SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"];
54
57
  export async function hold(args, options = {}) {
55
58
  const env = options.env ?? process.env, home = dsaHome(env), say = options.stderr ?? (l => process.stderr.write(`${l}\n`));
56
59
  const pollMs = options.pollMs ?? 250, graceMs = options.graceMs ?? 2000;
57
- const ticket = await enqueue(home, {
60
+ const request = {
58
61
  resource: args.resource, mode: args.mode,
59
62
  wrapper: { pid: process.pid, start: (await captureStart(process.pid)) || undefined },
60
63
  argv: args.argv.map(a => a.length > 200 ? `${a.slice(0, 199)}…` : a).slice(0, 32), cwd: process.cwd(),
61
64
  ...(args.note ? { note: args.note.slice(0, 300) } : {}), since: Date.now(),
62
65
  ...(env.DSA_EXEC ? { exec: env.DSA_EXEC } : {}), ...(env.DSA_CALL ? { call: env.DSA_CALL } : {}),
63
- });
66
+ };
67
+ // No wait: granted now, or refused without a ticket — never listed as a waiter, even for a moment.
68
+ const granted = args.maxWaitMs === 0 ? await tryGrant(home, request) : undefined;
69
+ if (granted && "busy" in granted) {
70
+ const now = Date.now(), by = granted.busy.map(t => `${who(t)} (${t.mode}${t.grantedAt === undefined ? ", waiting" : ""}, ${age(now - (t.grantedAt ?? t.since))})`).join(", ");
71
+ say(`hold: ${args.resource} is not free now (${by}); not running the command (exit ${WAIT_EXPIRED})`);
72
+ return WAIT_EXPIRED;
73
+ }
74
+ const ticket = granted ?? await enqueue(home, request);
64
75
  // Signals: while waiting they withdraw the request; while the command runs they go to its process group.
65
76
  let child, interrupted, wake = () => { };
66
77
  const onSignal = (signal) => {
@@ -125,7 +136,7 @@ export async function hold(args, options = {}) {
125
136
  }
126
137
  watcher?.close();
127
138
  watcher = undefined;
128
- ticket.grantedAt = Date.now();
139
+ ticket.grantedAt ??= Date.now();
129
140
  await writeTicket(home, ticket);
130
141
  if (interrupted) {
131
142
  removeTicket(home, ticket);
package/dist/cli/main.js CHANGED
@@ -132,7 +132,7 @@ export function serviceEntryError(entry) {
132
132
  return `install-service refuses to run from an npx cache (${entry}); the cache can be pruned and the service would break. Install the CLI with \`npm i -g pi-durable-subagents\` and run \`pi-durable-subagents install-service\` again.`;
133
133
  return undefined;
134
134
  }
135
- export const HELP = "pi-durable-subagents: smoke | status [wid] [--json] | events <wid> [--json] | events --all [--since <cursor>] [--limit <n>] [--json] [--wait-ms <n>] | tail [wid] [--json] | start | resume [wid] | drain | stop <wid|callId> | stop-all | run --request <id> --spec <file|-> [--labels <json>] [--cwd <dir>] [--json] [--wait-ms <n>] | send --request <id> --to <run-id|wid/key> [--call <key>] --kind follow-up|answer|steer|model [--qid <qid> --rev <n>] [--message <text|@file>] [--model <m>] [--json] [--wait-ms <n>] | stop --request <id> <run-id|wid|wid/key> [--json] [--wait-ms <n>] | describe --key <id> | describe <wid> [--json] | prune [wid] [--older-than <days>] | restart [--force <token> --reason <text>] | hold <resource> [--shared] [--max-wait <s>] [--note <text>] -- <command…> | leases [--json] | doctor [--json] | install-service [--dry-run] | uninstall-service [--dry-run] | chaos [--scenario <1-9>] [--keep] [--json]";
135
+ export const HELP = "pi-durable-subagents: smoke | status [wid] [--json] | events <wid> [--json] | events --all [--since <cursor>] [--limit <n>] [--json] [--wait-ms <n>] | tail [wid] [--json] | start | resume [wid] | drain | stop <wid|callId> | stop-all | run --request <id> --spec <file|-> [--labels <json>] [--cwd <dir>] [--json] [--wait-ms <n>] | send --request <id> --to <run-id|wid/key> [--call <key>] --kind follow-up|answer|steer|model [--qid <qid> --rev <n>] [--message <text|@file>] [--model <m>] [--json] [--wait-ms <n>] | stop --request <id> <run-id|wid|wid/key> [--json] [--wait-ms <n>] | describe --key <id> | describe <wid> [--json] | prune [wid] [--older-than <days>] | restart [--force <token> --reason <text>] | hold <resource> [--shared] [--max-wait <s> | --no-wait] [--note <text>] -- <command…> | leases [--json] | doctor [--json] | install-service [--dry-run] | uninstall-service [--dry-run] | chaos [--scenario <1-9>] [--keep] [--json]";
136
136
  /** Restart: the orchestrator exits when no execution runs (or `force`) and the installed version takes over. */
137
137
  async function restartCommand(home, env, write, options) {
138
138
  const body = { ...(typeof options.force === "string" ? { token: options.force } : options.force === true ? { force: true } : {}), ...(options.reason !== undefined ? { reason: options.reason } : {}), initiator: cliInitiator(env) };
@@ -373,7 +373,9 @@ async function sendRequest(args, ctx, seen) {
373
373
  const normalized = request({ action: "send", to: where.to, kind, ...(message !== undefined ? { message } : {}), ...(text(values, "model") !== undefined ? { model: text(values, "model") } : {}),
374
374
  ...(qid !== undefined ? { qid } : {}), ...(revision !== undefined ? { rev: revision } : {}) }, ctx.cwd ?? process.cwd());
375
375
  seen.digest = specDigest({ kind: "send", body: normalized.body, cond: normalized.cond });
376
- const done = await submitAndWait(ctx, seen, id, "send", normalized.body, normalized.cond, wait, json);
376
+ // Inside a subagent the CLI names its call (provenance for `answered.by`; spec_digest ignores it).
377
+ const body = ctx.env.DSA_CALL ? { ...normalized.body, caller: ctx.env.DSA_CALL } : normalized.body;
378
+ const done = await submitAndWait(ctx, seen, id, "send", body, normalized.cond, wait, json);
377
379
  if ("code" in done)
378
380
  return done.code;
379
381
  return decided(ctx, id, done, json);
@@ -23,11 +23,15 @@ export function labelsOf(body) {
23
23
  return entries.length && entries.every(([, v]) => typeof v === "string") ? Object.fromEntries(entries) : undefined;
24
24
  }
25
25
  /** `answered.by` from the answer request's sender: a pi session `main:<id>` → `session:<id>` (+ via "ui" when the
26
- * subagent list sent it, SendBody.by "user"); the CLI sender `cli:<user>@<host>` as is; anything else `unknown`. */
26
+ * subagent list sent it, SendBody.by "user"); the CLI run inside a subagent (SendBody.caller `<wid>@<rev>/<key>@<gen>`)
27
+ * → `call:<wid>/<key>`; any other CLI sender `cli:<user>@<host>` as is; anything else `unknown`. */
27
28
  export function answeredBy(req) {
28
- const from = req?.from ?? "";
29
+ const from = req?.from ?? "", body = req?.body;
29
30
  if (from.startsWith("main:"))
30
- return { by: `session:${from.slice(5)}`, ...(req.body?.by === "user" ? { via: "ui" } : {}) };
31
+ return { by: `session:${from.slice(5)}`, ...(body?.by === "user" ? { via: "ui" } : {}) };
32
+ const caller = typeof body?.caller === "string" ? /^([^/@]+)@\d+\/(.+)@\d+$/.exec(body.caller) : null;
33
+ if (from.startsWith("cli:") && caller)
34
+ return { by: `call:${caller[1]}/${caller[2]}` };
31
35
  if (/^cli:[^@]+@.+$/.test(from))
32
36
  return { by: from };
33
37
  return { by: "unknown" };
@@ -136,7 +136,9 @@ export function evidence(entries, exec) {
136
136
  /** Only explicit payment failures are terminal; rate limits, overload and transport errors still retry, and a used-up
137
137
  * usage window (`quotaExhausted`) waits for the provider or moves to another one. */
138
138
  export function fatalProviderError(text) {
139
- return /\b402\b|insufficient[_ ]?(quota|balance|funds)|billing|credit balance|余额/i.test(text);
139
+ // Chinese "balance", but not inside "remaining quota": "remaining quota is 0, resets at 00:00 the next day" is a daily
140
+ // window. CJK text is written as \u escapes (the repository is ASCII-only English).
141
+ return /\b402\b|insufficient[_ ]?(quota|balance|funds)|billing|credit balance|(?<!\u5269)\u4f59\u989d/i.test(text);
140
142
  }
141
143
  /** A provider's usage window is used up: its requests are refused (and not counted) until the window resets, hours
142
144
  * later. Seen as a gateway's `503 No available accounts` once pi's own retries are spent, or a usage-limit message.
@@ -150,7 +152,7 @@ export function quotaExhausted(text) {
150
152
  // per minute"): pi's retries and the lost-execution path handle it; it must not take the provider out for minutes.
151
153
  if (/rate.?limit|too many requests|request limit|per (second|minute)|\b[RT]PM\b|resets? in \d+ ?(ms|s|secs?|seconds?|minutes?)\b/i.test(text))
152
154
  return false;
153
- return /usage limit|quota (exceeded|exhausted)|exceeded your (current )?(usage|quota)|limit (reached|exceeded)[^.]*resets?\b|额度/i.test(text);
155
+ return /usage limit|quota (exceeded|exhausted)|exceeded your (current )?(usage|quota)|limit (reached|exceeded)[^.]*resets?\b|\u989d\u5ea6/i.test(text);
154
156
  }
155
157
  /** A refusal of the request's content (terms of service, usage or content policy): the same request is refused again,
156
158
  * on this provider and usually on another, so it is reported at once instead of retried as a lost execution. */
@@ -159,6 +159,16 @@ export function removeTicket(home, t) {
159
159
  /** Queue a request: number it under the resource's kernel lock and write its ticket before the lock is released, so a
160
160
  * later request always sees it. */
161
161
  export async function enqueue(home, ticket, lock = new OsLock()) {
162
+ return (await numbered(home, ticket, false, lock));
163
+ }
164
+ /** Take the lease now or not at all (`hold --max-wait 0`): under the resource's kernel lock, either no live ticket
165
+ * blocks it and its ticket is written already granted, or nothing is written and the blockers are returned. A refused
166
+ * request is never visible as a waiter, and a granted one never was one. Granting needs no more than the lock: a later
167
+ * request gets a higher number and waits behind this ticket, and earlier tickets can only disappear. */
168
+ export async function tryGrant(home, ticket, lock = new OsLock()) {
169
+ return numbered(home, ticket, true, lock);
170
+ }
171
+ async function numbered(home, ticket, now, lock) {
162
172
  const dir = leaseDir(home, ticket.resource);
163
173
  mkdirSync(dir, { recursive: true });
164
174
  let handle = await lock.tryAcquire(path.join(dir, ".lock"));
@@ -171,6 +181,12 @@ export async function enqueue(home, ticket, lock = new OsLock()) {
171
181
  const last = Number.parseInt(await readFile(counter, "utf8").catch(() => "0"), 10) || 0;
172
182
  const highest = readTickets(home, ticket.resource).reduce((m, t) => Math.max(m, t.seq), last);
173
183
  const t = { ...ticket, seq: highest + 1 };
184
+ if (now) {
185
+ const busy = blockers(t, liveTickets(home, ticket.resource));
186
+ if (busy.length)
187
+ return { busy };
188
+ t.grantedAt = Date.now();
189
+ }
174
190
  await writeFile(`${counter}.tmp`, String(t.seq));
175
191
  renameSync(`${counter}.tmp`, counter);
176
192
  writeTicketSync(home, t);
package/dist/requests.js CHANGED
@@ -29,6 +29,10 @@ export function specDigest(req) {
29
29
  const { origin: _, ...rest } = body;
30
30
  body = rest;
31
31
  }
32
+ if (req.kind === 'send' && body && typeof body === 'object' && !Array.isArray(body)) {
33
+ const { caller: _, ...rest } = body;
34
+ body = rest;
35
+ }
32
36
  return contentHash({ kind: req.kind, body, cond: req.cond });
33
37
  }
34
38
  /** The envelope recorded for `rid`: the orchestrator's admitted copy (ledger `request`, kept after prune), else any
@@ -86,8 +86,8 @@ export function thoughtSummary(text) {
86
86
  return (headings.at(-1)[1] ?? headings.at(-1)[2]).trim();
87
87
  return lastSentence(text);
88
88
  }
89
- const STOPS = new Set([...".!?。!?"]);
90
- /** The last match of /[^.!?。!?]+[.!?。!?](?=\s|$)/gu without the regex: a long thought with no sentence end made
89
+ const STOPS = new Set([...".!?\u3002\uff01\uff1f"]);
90
+ /** The last match of /[^.!?\u3002\uff01\uff1f]+[.!?\u3002\uff01\uff1f](?=\s|$)/gu without the regex: a long thought with no sentence end made
91
91
  * that regex retry from every position (quadratic), which stalled pi's startup on long histories. Such a match is a
92
92
  * whole run of non-stop characters followed by one stop that ends the text or precedes whitespace. */
93
93
  function lastSentence(text) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-durable-subagents",
3
- "version": "1.0.25",
3
+ "version": "1.0.27",
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",