@sleepy_panda_srl/portulan 0.1.2 → 0.1.3

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/cli/stop-gate.mjs CHANGED
@@ -194,6 +194,28 @@ export const MAX_BLOCKS = 3;
194
194
  // refusal, or a two-reason session would reach the ceiling in half the attempts.
195
195
  export const MAX_TOTAL_BLOCKS = 9;
196
196
 
197
+ // **Both caps above are keyed to `session_id`, and a host that rotates it per retry defeats them.**
198
+ //
199
+ // Measured 2026-09-02 on Claude Code 2.1.251, inside an A/B arm: the gate refused a stop, the agent
200
+ // retried, and the retry arrived under a NEW session id — so `counterFile()` opened a fresh counter,
201
+ // every consecutive count restarted at zero, and neither cap was ever reached. **85,839 hook
202
+ // invocations and 6,985 distinct counter files from one probe**, none of them ever a fourth consecutive
203
+ // refusal. This module's own comment names the result exactly: *a Stop-gate that cannot stop is not a
204
+ // gate, it is a hang.*
205
+ //
206
+ // The platform already answers this and the answer was documented here and never read. `stop_hook_active`
207
+ // is true precisely when the stop being judged was itself provoked by a hook block — so it identifies
208
+ // the retry CHAIN without reference to any session id. This counter follows it: cleared on a stop that
209
+ // no hook provoked, incremented on one that a hook did, and keyed to the TREE alone, which is the thing
210
+ // that cannot rotate underneath it.
211
+ //
212
+ // **It is a backstop, not a replacement.** The per-reason and per-session caps keep their meaning where
213
+ // the id is stable, which is every ordinary session; this only bounds the case where they cannot see
214
+ // their own history. It is set above `MAX_TOTAL_BLOCKS` deliberately: a session whose id is stable must
215
+ // always meet the specific caps first, so their messages, not this one, are what a person normally
216
+ // reads.
217
+ export const MAX_CHAIN_BLOCKS = 12;
218
+
197
219
  const RECIPE_TIMEOUT_MS = 90_000;
198
220
 
199
221
  /**
@@ -218,6 +240,74 @@ function counterFile(sessionId, dir, root = REPO) {
218
240
  return path.join(dir, `portulan-stopgate-${readable}-${digest(sessionId)}-${digest(root)}`);
219
241
  }
220
242
 
243
+ /**
244
+ * Where the retry CHAIN's counter lives — keyed by tree, never by session.
245
+ *
246
+ * `counterFile()` is keyed by session id and is the right key for the per-reason caps. This one exists
247
+ * for the case that key cannot survive: a host issuing a new session id per retry. So it is deliberately
248
+ * the one piece of gate state two sessions in a worktree share — the sharing the per-session counters
249
+ * exist to prevent is exactly what makes this able to see a chain the counters cannot.
250
+ *
251
+ * **"The tree" means the tree being JUDGED, and the first cut keyed it to `REPO` instead.** `bumpChain`
252
+ * called this without a root, so every session shared one file whatever `resolveSessionTree(payload.cwd)`
253
+ * had resolved — and this gate answers about another worktree often enough that line ~775 has a branch
254
+ * for it. Two arms, or an arm and this repository, contended on one chain: one tree's retries could
255
+ * spend another tree's budget, or release it early. The docblock above said *keyed to the tree* while
256
+ * the code keyed to a constant. Copilot round 2 on
257
+ * [#406](https://github.com/sleepy-panda-srl/portulan/pull/406).
258
+ */
259
+ function chainFile(dir, root = REPO) {
260
+ const digest = (v) => {
261
+ let h = 0;
262
+ for (const ch of String(v)) h = (h * 31 + ch.charCodeAt(0)) >>> 0;
263
+ return h.toString(36);
264
+ };
265
+ return path.join(dir, `portulan-stopgate-chain-${digest(root)}`);
266
+ }
267
+
268
+ /**
269
+ * How many consecutive hook-provoked stops this tree has seen, and the bump that maintains it.
270
+ *
271
+ * `provoked` is the payload's `stop_hook_active`: false means this stop was not caused by a block, so
272
+ * whatever chain existed has ended. Reading a missing or unreadable file as zero is the stricter
273
+ * direction for the per-session counters and the LOOSER one here, so it is stated rather than inherited:
274
+ * a chain this gate cannot count is a chain it does not charge, and the specific caps still apply.
275
+ */
276
+ export function bumpChain(provoked, dir = os.tmpdir(), root = REPO) {
277
+ const file = chainFile(dir, root);
278
+ if (!provoked) {
279
+ try {
280
+ fs.rmSync(file, { force: true });
281
+ } catch {
282
+ // A chain counter that cannot be cleared costs a later session part of its budget, which is
283
+ // the safe direction: it can only make this backstop fire sooner, never later.
284
+ }
285
+ return 0;
286
+ }
287
+ let previous = 0;
288
+ try {
289
+ previous = Number(JSON.parse(fs.readFileSync(file, "utf8"))?.chain) || 0;
290
+ } catch {
291
+ previous = 0;
292
+ }
293
+ // **The count advances only if the write does, and the first cut returned the increment either
294
+ // way.** That made the returned value diverge from what the next invocation would read: on an
295
+ // unwritable temp dir the chain appeared to climb within one call and reset on every following one,
296
+ // so the bound could fire on a session that had never accumulated anything, or never fire at all —
297
+ // and the comment below asserted the opposite of what the code did, which is the class this whole
298
+ // change is about. Copilot round 1 on [#406](https://github.com/sleepy-panda-srl/portulan/pull/406).
299
+ //
300
+ // Unwritable state therefore means the chain **cannot grow**: this backstop simply does not fire and
301
+ // the specific caps are unaffected. Silent because it is a fact about the filesystem, not about the
302
+ // session's work.
303
+ try {
304
+ fs.writeFileSync(file, JSON.stringify({ chain: previous + 1 }));
305
+ } catch {
306
+ return previous;
307
+ }
308
+ return previous + 1;
309
+ }
310
+
221
311
  /** The stored shape: one consecutive count per reason, plus the non-resetting refusal total. */
222
312
  function readCount(file) {
223
313
  let stored = {};
@@ -725,7 +815,7 @@ function collectProblems(tree = { root: REPO, workspace: WORKSPACE, origin: "tol
725
815
  * block — refuse this stop, and charge it.
726
816
  * release — the cap is spent. The session may end, and the record must say RED rather than done.
727
817
  */
728
- export function verdict({ problems, counts = {}, total = 0, max = MAX_BLOCKS, maxTotal = MAX_TOTAL_BLOCKS }) {
818
+ export function verdict({ problems, counts = {}, total = 0, chain = 0, max = MAX_BLOCKS, maxTotal = MAX_TOTAL_BLOCKS, maxChain = MAX_CHAIN_BLOCKS }) {
729
819
  if (problems.length === 0) return { action: "allow" };
730
820
 
731
821
  const text = problems.map((p) => p.text).join("\n\n");
@@ -734,14 +824,22 @@ export function verdict({ problems, counts = {}, total = 0, max = MAX_BLOCKS, ma
734
824
  // stop through on the strength of a problem that no longer exists.
735
825
  const spent = problems.map((p) => p.reason).filter((reason) => (counts[reason] ?? 0) > max);
736
826
 
737
- if (spent.length || total > maxTotal) {
827
+ if (spent.length || total > maxTotal || chain > maxChain) {
738
828
  // Name the bound that actually released it. Saying "cap of 3" after nine refusals with a
739
829
  // per-reason count of 1 is false, and it is the same defect already fixed once in this very
740
830
  // message — a release that misreports why it happened sends a reader to the wrong constant.
741
831
  // Per-reason counters add one more way to be wrong: naming the cap without naming whose.
832
+ // The chain bound is named LAST, so the two session-keyed bounds keep reporting themselves
833
+ // wherever they can — a session whose id is stable must always meet those first. Reaching this
834
+ // one is itself a finding about the host: it means the per-session counters could not see their
835
+ // own history, which is what 2.1.251 does inside an isolated arm.
742
836
  const bound = spent.length
743
837
  ? `the cap of ${max} consecutive refusals for \`${spent.join("`, `")}\` was reached`
744
- : `the absolute ceiling of ${maxTotal} refusals was reached (no single reason had reached its cap of ${max})`;
838
+ : total > maxTotal
839
+ ? `the absolute ceiling of ${maxTotal} refusals was reached (no single reason had reached its cap of ${max})`
840
+ : `${chain} consecutive hook-provoked stops were seen in this tree, past the chain bound of ${maxChain} — ` +
841
+ `the per-session caps never fired, which means this host issued a new session id per retry and their ` +
842
+ `counters could not see their own history`;
745
843
  return {
746
844
  action: "release",
747
845
  message:
@@ -804,7 +902,12 @@ function main() {
804
902
  const state = problems.length === 0
805
903
  ? { counts: {}, total: 0 }
806
904
  : bumpCount(sessionId, [...new Set(problems.map((p) => p.reason))]);
807
- const result = verdict({ problems, counts: state.counts, total: state.total });
905
+ // **The chain follows the platform's own signal, not ours.** `stop_hook_active` is true exactly when
906
+ // the stop being judged was provoked by a hook block, so it identifies the retry chain without any
907
+ // session id. Cleared on a stop nothing provoked — including a green one, which ends a chain as
908
+ // surely as a fixed problem does.
909
+ const chain = bumpChain(problems.length > 0 && payload.stop_hook_active === true, os.tmpdir(), tree.root);
910
+ const result = verdict({ problems, counts: state.counts, total: state.total, chain });
808
911
 
809
912
  if (result.action === "allow") allow();
810
913
  if (result.action === "release") {