@tacuchi/agent-workflow-cli 21.11.0 → 21.12.0

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.
@@ -12,15 +12,45 @@
12
12
  * before the single write happens, so a failure halfway through leaves the
13
13
  * previous state exactly as it was rather than a half-applied one.
14
14
  */
15
- import { join } from "node:path";
16
- import { FLOW_RUN_STATE_FILE, parseRunState, serializeRunState, } from "../../domain/flow/run-state.js";
15
+ import { dirname, join } from "node:path";
16
+ import { FLOW_RUN_STATE_FILE, atCurrentVersion, parseRunState, serializeRunState, withAttemptCounters, } from "../../domain/flow/run-state.js";
17
17
  import { LockBusyError, acquireLock } from "../lock-service.js";
18
+ import { semanticDigest } from "../semantic-operation/protocol.js";
19
+ /**
20
+ * The attempt counter: outside the run state's seal, and outside its FOLDER.
21
+ *
22
+ * Two evasions, and the file's location answers the second one. The seal proved
23
+ * it detects an edit and does not detect a restore: copying an earlier
24
+ * `.flow-run.json` back over the current one is accepted — the file is
25
+ * internally consistent, because it really was written by this CLI — and it
26
+ * takes the attempt ledger back with it. So the count lives in a second file
27
+ * that only ever grows. But while that file sat NEXT to the state, inside the
28
+ * session folder, a `cp -r` of the folder carried both away and back, and
29
+ * deleting it reset the cap: the counter defended against restoring a file and
30
+ * fell to restoring the directory that contained it, which is the same move one
31
+ * level up.
32
+ *
33
+ * Now it is workspace runtime — `.<ns>/sessions/.flow-attempts/<folder>.json`,
34
+ * keyed by the session it counts, dot-prefixed so the session listing skips it
35
+ * and already inside the gitignore the CLI manages. Restoring a session folder
36
+ * cannot lower it, deleting the folder cannot delete it, and a recovery does not
37
+ * delete it either: it records how many attempts were given back. See
38
+ * `attemptsAt`.
39
+ *
40
+ * One file per run rather than one registry for the workspace, because two runs
41
+ * advance concurrently under two DIFFERENT locks: a shared registry would need a
42
+ * lock of its own, and losing that race would silently drop a run's floor —
43
+ * rebuilding the very hole the file exists to close.
44
+ */
45
+ const COUNTER_VERSION = 2;
46
+ const NO_COUNTERS = { attempts: {}, granted: {} };
18
47
  export function locateRun(paths, session) {
19
48
  const dir = join(paths.cwdSessionsDir(), session);
20
49
  return {
21
50
  session,
22
51
  dir,
23
52
  statePath: join(dir, FLOW_RUN_STATE_FILE),
53
+ countersPath: paths.cwdFlowAttemptsFile(session),
24
54
  lockPath: join(dir, `${FLOW_RUN_STATE_FILE}.lock`),
25
55
  };
26
56
  }
@@ -43,7 +73,187 @@ export async function readRun(fs, location) {
43
73
  },
44
74
  };
45
75
  }
46
- return parseRunState(await fs.readText(location.statePath));
76
+ const parsed = parseRunState(await fs.readText(location.statePath));
77
+ if (!parsed.ok)
78
+ return parsed;
79
+ // Reconciled on the way OUT, never on the way in: whoever restored an older
80
+ // state has already handed it to us, and raising the floor here is what makes
81
+ // the restore worthless. Every reader goes through this function, so no surface
82
+ // of the CLI can see a run with attempts the counter says were already spent.
83
+ const counters = await readCounters(fs, location);
84
+ if (!counters.ok)
85
+ return counters;
86
+ const rolledBack = checkAgainstSealedFloor(parsed.state, counters.value);
87
+ if (rolledBack !== null)
88
+ return { ok: false, failure: rolledBack };
89
+ return {
90
+ ok: true,
91
+ state: withAttemptCounters(atCurrentVersion(parsed.state), {
92
+ floor: counters.value.attempts,
93
+ grants: counters.value.granted,
94
+ }),
95
+ };
96
+ }
97
+ /**
98
+ * Whether the counter came back BEHIND the floor the state itself carries sealed.
99
+ *
100
+ * The write order is the counter first and the state second, so the counter is
101
+ * never behind: a process that dies between the two leaves it AHEAD, which costs
102
+ * an attempt nobody spent, and that is the only one of the two errors that is
103
+ * safe to make. The reverse cannot happen by running this CLI. It happens when
104
+ * the counter file is deleted, truncated or replaced by an older copy while the
105
+ * state stays — surgery on the accounting, and the only evidence of it that
106
+ * survives inside the seal.
107
+ *
108
+ * Refused rather than rebuilt. Rebuilding from the state is precisely what the
109
+ * counter exists to not do: the state is the file a restore rolls back, and
110
+ * seeding the floor from it would hand the evader the reset they came for while
111
+ * calling it a repair.
112
+ */
113
+ function checkAgainstSealedFloor(state, counters) {
114
+ const behind = (sealed, live) => Object.entries(sealed ?? {}).find(([transition, count]) => count > (live[transition] ?? 0));
115
+ const lost = behind(state.attempt_floor, counters.attempts) ??
116
+ behind(state.attempt_grants, counters.granted);
117
+ if (lost === undefined)
118
+ return null;
119
+ return {
120
+ code: "FLOW_RUN_COUNTER_ROLLED_BACK",
121
+ message: `el contador de intentos quedó detrás del estado sellado: '${lost[0]}' declara ${lost[1]} y el contador no los tiene`,
122
+ action: "el contador se borró o se restauró una copia anterior: restaurá el archivo de intentos de la corrida, o descartá la corrida entera y re-adoptá la sesión con 'aw flow advance --flow <flow> --adopt' — no se reconstruye desde el estado, que es justo lo que una restauración rebobina",
123
+ };
124
+ }
125
+ /**
126
+ * The counter as it stands, or the conservative reading when there is none.
127
+ *
128
+ * An ABSENT file is the normal state of every run that started before the counter
129
+ * existed, and of every run that has not spent an attempt yet: it reads as "no
130
+ * floor beyond the ledger", the run keeps walking, and the next write seeds the
131
+ * file from the ledger it already has. Absence is not a hole — a state that DID
132
+ * spend attempts carries their floor inside its own seal, so an absent counter
133
+ * under such a state is caught by {@link checkAgainstSealedFloor} rather than
134
+ * read as zero.
135
+ *
136
+ * Everything else is refused with a cause, and the checks are the state file's
137
+ * own, in the same order: shape, then version, then the SEAL, then coherence.
138
+ * This file is read fail-closed for exactly the reason the state is — it was the
139
+ * surface added to resist manipulation, and while it was the only unsealed file
140
+ * of the run it was also the easiest one to manipulate: writing `granted: 99`
141
+ * into it turned the cap off for good.
142
+ */
143
+ async function readCounters(fs, location) {
144
+ if (!(await fs.exists(location.countersPath)))
145
+ return { ok: true, value: NO_COUNTERS };
146
+ const refuse = (why) => ({
147
+ ok: false,
148
+ failure: {
149
+ code: "FLOW_RUN_COUNTER_INVALID",
150
+ message: `el contador de intentos de la corrida ${why}`,
151
+ action: "no se avanza con una contabilidad de intentos ilegible ni con una que fue editada fuera del CLI: restaurá el archivo, o descartá la corrida entera y re-adoptá la sesión con 'aw flow advance --flow <flow> --adopt'",
152
+ },
153
+ });
154
+ let parsed;
155
+ try {
156
+ parsed = JSON.parse(await fs.readText(location.countersPath));
157
+ }
158
+ catch {
159
+ return refuse("no es JSON válido");
160
+ }
161
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
162
+ return refuse("no es un objeto JSON");
163
+ }
164
+ const record = parsed;
165
+ if (record.version !== COUNTER_VERSION) {
166
+ return refuse(`declara una versión que este CLI no lee: ${String(record.version)}`);
167
+ }
168
+ const attempts = readCounterMap(record.attempts);
169
+ const granted = readCounterMap(record.granted);
170
+ if (attempts === null || granted === null)
171
+ return refuse("no es un contador por transición");
172
+ const value = { attempts, granted };
173
+ if (record.digest !== counterDigest(location.session, value)) {
174
+ return refuse("no coincide con su propio sello");
175
+ }
176
+ // A grant is what a recovery forgave, and forgiving more than was ever spent
177
+ // is not a state this CLI can produce: `raiseCounters` only ever copies the
178
+ // grants the state recorded, and a recovery grants exactly what it found
179
+ // spent. A file that says otherwise is asking for a cap that never fires.
180
+ const forgiven = Object.entries(granted).find(([id, count]) => count > (attempts[id] ?? 0));
181
+ if (forgiven !== undefined) {
182
+ return refuse(`perdona ${forgiven[1]} intentos de '${forgiven[0]}' y solo registra ${attempts[forgiven[0]] ?? 0}`);
183
+ }
184
+ return { ok: true, value };
185
+ }
186
+ /**
187
+ * The counter's seal — the same canonicalization the rest of the protocol uses.
188
+ *
189
+ * The SESSION is inside it, and not as decoration: the file lives in a shared
190
+ * runtime folder keyed by folder name, so sealing the key is what makes one run's
191
+ * counter unusable as another's.
192
+ */
193
+ function counterDigest(session, counters) {
194
+ return semanticDigest({ version: COUNTER_VERSION, session, ...counters });
195
+ }
196
+ /**
197
+ * Raise the counter to what this state knows, and hand the state back reconciled.
198
+ *
199
+ * Monotone by construction on both halves: the attempts seen can only go up —
200
+ * `Math.max` against what the file already said — and the grants a recovery
201
+ * recorded come from the state, which only ever adds to them. A state written
202
+ * from a restored ledger therefore raises nothing and gets the live floor back.
203
+ */
204
+ async function raiseCounters(fs, location, state) {
205
+ const current = await readCounters(fs, location);
206
+ if (!current.ok)
207
+ return current;
208
+ const spent = new Map();
209
+ for (const attempt of state.attempts) {
210
+ spent.set(attempt.transition, (spent.get(attempt.transition) ?? 0) + 1);
211
+ }
212
+ const attempts = { ...current.value.attempts };
213
+ for (const [transition, count] of spent) {
214
+ attempts[transition] = Math.max(attempts[transition] ?? 0, count);
215
+ }
216
+ const granted = { ...current.value.granted };
217
+ for (const [transition, forgiven] of Object.entries(state.attempt_grants ?? {})) {
218
+ granted[transition] = Math.max(granted[transition] ?? 0, forgiven);
219
+ }
220
+ const next = { attempts, granted };
221
+ if (changed(current.value, next))
222
+ await writeCounters(fs, location, next);
223
+ return { ok: true, state: withAttemptCounters(state, { floor: attempts, grants: granted }) };
224
+ }
225
+ async function writeCounters(fs, location, counters) {
226
+ await fs.mkdirp(dirname(location.countersPath));
227
+ await fs.writeText(location.countersPath, `${JSON.stringify({
228
+ version: COUNTER_VERSION,
229
+ session: location.session,
230
+ ...counters,
231
+ digest: counterDigest(location.session, counters),
232
+ }, null, 2)}\n`);
233
+ }
234
+ /**
235
+ * Start the counter over for a run that is being created.
236
+ *
237
+ * The file goes rather than being zeroed, so an adopted run is byte-identical to
238
+ * one that never had a counter: the next attempt seeds it again from the ledger.
239
+ */
240
+ async function resetCounters(fs, location, state) {
241
+ await fs.remove(location.countersPath);
242
+ return { ok: true, state: withAttemptCounters(state, { floor: {}, grants: {} }) };
243
+ }
244
+ function changed(before, after) {
245
+ return (JSON.stringify(before.attempts) !== JSON.stringify(after.attempts) ||
246
+ JSON.stringify(before.granted) !== JSON.stringify(after.granted));
247
+ }
248
+ function readCounterMap(value) {
249
+ if (value === undefined)
250
+ return {};
251
+ if (typeof value !== "object" || value === null || Array.isArray(value))
252
+ return null;
253
+ const entries = Object.entries(value);
254
+ if (!entries.every(([, count]) => Number.isInteger(count) && count >= 0))
255
+ return null;
256
+ return Object.fromEntries(entries);
47
257
  }
48
258
  /**
49
259
  * Run one mutation under the run's lock and persist its result.
@@ -95,10 +305,31 @@ export async function applyUnderLock(fs, location, mutate, options = {}) {
95
305
  return result;
96
306
  if (result.persist === false)
97
307
  return result;
308
+ // The counter first, the state second, and the order is the contract. If the
309
+ // process dies between them the counter is AHEAD of the ledger, which costs
310
+ // an attempt nobody spent; the other order would leave it BEHIND, which is an
311
+ // attempt somebody spent and can spend again. Only one of those two errors is
312
+ // safe to make.
313
+ //
314
+ // An ADOPTION is the one case that starts the counter over, and it is not a
315
+ // loophole in the monotonicity: a state that was absent means this run is
316
+ // being created now, and the only way to get there deliberately is to throw
317
+ // the previous run away whole — every applied transition with it. What the
318
+ // counter defends against is keeping the position and losing the attempts;
319
+ // paying for a reset with the entire run is not that trade. Keeping the old
320
+ // count would be worse than useless: re-adopting after a corrupt state — the
321
+ // repair this CLI prints — would come back pre-exhausted at its first gate.
322
+ const raised = state === null
323
+ ? await resetCounters(fs, location, result.state)
324
+ : await raiseCounters(fs, location, result.state);
325
+ if (!raised.ok)
326
+ return raised;
98
327
  // One write, after the next state is complete and sealed: nothing partial
99
328
  // can be observed because nothing partial is ever written.
100
- await fs.writeText(location.statePath, serializeRunState(result.state));
101
- return result;
329
+ await fs.writeText(location.statePath, serializeRunState(raised.state));
330
+ // The reconciled state is what the caller gets back, because the digest it
331
+ // holds is the one the next compare-and-swap will be judged against.
332
+ return { ...result, state: raised.state };
102
333
  }
103
334
  finally {
104
335
  await lock.release();
@@ -1 +1 @@
1
- {"version":3,"file":"run-state-service.js","sourceRoot":"","sources":["../../../src/application/flow/run-state-service.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"run-state-service.js","sourceRoot":"","sources":["../../../src/application/flow/run-state-service.ts"],"names":[],"mappings":"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"}
@@ -21,14 +21,14 @@
21
21
  * `renderHuman`, and a boundary nobody can see is a boundary that did not happen.
22
22
  */
23
23
  import { join } from "node:path";
24
- import { authorizeEffects } from "../../domain/capability/effects.js";
24
+ import { authorizeEffects, touchesTheWorld, } from "../../domain/capability/effects.js";
25
25
  import { AttemptLedger } from "../../domain/capability/protocol.js";
26
- import { claimedSeal, parseFlowAnswer } from "../../domain/flow/answer.js";
26
+ import { claimedSeal, parseFlowAnswer, spendsAttempt, } from "../../domain/flow/answer.js";
27
27
  import { actionOf, approvalGrantOf, effectsOf, internalActionOf, journeyOfFlow, proposalContractOf, publishApprovalOf, scopesSources, } from "../../domain/flow/authority.js";
28
28
  import { effectApprovalDigest } from "../../domain/flow/authorization.js";
29
29
  import { PAUSE_LABEL, STOP_LABEL, stepOf, } from "../../domain/flow/directive.js";
30
30
  import { executionVerdict } from "../../domain/flow/execution-result.js";
31
- import { applyTransition, checkAgainstJourney, withApproval, withAttempt, withBoundary, withObservation, withProposal, withScope, } from "../../domain/flow/run-state.js";
31
+ import { applyTransition, checkAgainstJourney, restatesLastEvent, withApproval, withAttempt, withBoundary, withEvent, withObservation, withProposal, withScope, } from "../../domain/flow/run-state.js";
32
32
  import { destinationsOf, sealProposal } from "../../domain/proposal.js";
33
33
  import { baseDigest } from "../../domain/proposal.js";
34
34
  import { reservationMarker } from "../../domain/reservation.js";
@@ -211,8 +211,9 @@ function decide(state, input, snapshot) {
211
211
  // nothing — matching a recorded attempt requires resending exactly what already
212
212
  // ran, and a retry applies nothing.
213
213
  const identity = attemptIdentity(state, input, claimedSeal(input.raw) ?? resolved.seal, resolved.stopped.id);
214
+ const cost = { journey, identity };
214
215
  // 1 · Resend, before anything else.
215
- const resend = resendCheck(state, resolved, identity);
216
+ const resend = resendCheck(state, resolved, cost);
216
217
  if (resend !== null)
217
218
  return resend;
218
219
  // The action the run is waiting on is the one that was EMITTED, and the seal
@@ -220,11 +221,11 @@ function decide(state, input, snapshot) {
220
221
  // is the diagnosis: when the two differ, the invocation changed underneath a run
221
222
  // in flight (a CLI upgraded mid-run), which is a different problem from a state
222
223
  // that moved — and it has a different answer.
223
- const drifted = actionDrift(state, resolved);
224
+ const drifted = actionDrift(state, resolved, cost);
224
225
  if (drifted !== null)
225
226
  return drifted;
226
227
  // 2 · The boundary in force decides what is admissible.
227
- const admissible = admit(state, resolved, resolved.stopped, input, { journey, identity });
228
+ const admissible = admit(state, resolved, resolved.stopped, input, cost);
228
229
  if ("decision" in admissible)
229
230
  return admissible.decision;
230
231
  const parsed = admissible;
@@ -237,17 +238,11 @@ function decide(state, input, snapshot) {
237
238
  // preview showed. Neither ever applies the step by itself.
238
239
  const scoped = scopeFrom(state, resolved.stopped, parsed.answer, snapshot.scope);
239
240
  if ("failure" in scoped) {
240
- return reject(state, resolved, scoped.failure.message, {
241
- code: scoped.failure.code,
242
- action: scoped.failure.action,
243
- });
241
+ return reject(state, resolved, scoped.failure.message, { code: scoped.failure.code, action: scoped.failure.action }, cost);
244
242
  }
245
243
  const sealed = sealFrom(scoped.state, resolved.stopped, parsed.answer, snapshot.destinations);
246
244
  if ("failure" in sealed) {
247
- return reject(state, resolved, sealed.failure.message, {
248
- code: sealed.failure.code,
249
- action: sealed.failure.action,
250
- });
245
+ return reject(state, resolved, sealed.failure.message, { code: sealed.failure.code, action: sealed.failure.action }, cost);
251
246
  }
252
247
  const granted = resolved.kind === "authorization" ? (resolved.authorization?.planned ?? []) : [];
253
248
  const approved = grantOf(sealed.state, resolved, parsed.answer, granted, journey);
@@ -336,8 +331,16 @@ function scopeFrom(state, stopped, answer, snapshot) {
336
331
  }
337
332
  return { state: withScope(state, { plan, sources }) };
338
333
  }
334
+ /**
335
+ * A scope answer the boundary read and refused — under its OWN code.
336
+ *
337
+ * It reported `FLOW_ANSWER_INVALID` for a while, and that code means "the
338
+ * envelope could not be read as an answer", so the same string stood for two
339
+ * opposite facts: one that must not cost an attempt and one that must. A reader
340
+ * cannot tell them apart, and neither could the table that decides the charge.
341
+ */
339
342
  function invalidScope(message, action) {
340
- return { code: "FLOW_ANSWER_INVALID", message, action };
343
+ return { code: "FLOW_SCOPE_INVALID", message, action };
341
344
  }
342
345
  /**
343
346
  * Seat the proposal an authoring answer just handed over — sealed, not believed.
@@ -498,7 +501,7 @@ function grantOf(state, resolved, answer, granted, journey) {
498
501
  * a payload the boundary's own contract rejects, a decline (a real answer that
499
502
  * applies nothing), and an execution result that did not earn its transition.
500
503
  */
501
- function admit(state, resolved, stopped, input, spent) {
504
+ function admit(state, resolved, stopped, input, cost) {
502
505
  const expectedApproval = resolved.kind === "authorization"
503
506
  ? effectApprovalDigest(stopped.id, resolved.authorization?.planned ?? [])
504
507
  : null;
@@ -514,11 +517,13 @@ function admit(state, resolved, stopped, input, spent) {
514
517
  request: resolved.request,
515
518
  });
516
519
  if (!parsed.ok) {
517
- // An answer the boundary refused IS an attempt spent: it is the exact event
518
- // the chassis' cap counts. Recorded here and nowhere else a resend, a
519
- // decline and a pause are not failed tries at resolving the gap.
520
+ // An answer the boundary EVALUATED and refused is an attempt spent: it is the
521
+ // exact event the chassis' cap counts. A payload it could not read as an
522
+ // answer at all is not see {@link FLOW_ANSWER_REJECTIONS}, which `reject`
523
+ // consults. A resend, a decline and a pause are not failed tries either, and
524
+ // they are classified there rather than being exempted here.
520
525
  return {
521
- decision: reject(state, resolved, parsed.failure.message, { code: parsed.failure.code, action: parsed.failure.action }, spent),
526
+ decision: reject(state, resolved, parsed.failure.message, { code: parsed.failure.code, action: parsed.failure.action }, cost),
522
527
  };
523
528
  }
524
529
  // The flow control is a real answer, and neither half applies anything. They
@@ -532,7 +537,7 @@ function admit(state, resolved, stopped, input, spent) {
532
537
  code: "FLOW_BOUNDARY_PAUSED",
533
538
  action: "escribí el CHECKPOINT con 'aw checkpoint-write', compactá, y volvé con 'aw flow advance' a esta misma frontera",
534
539
  outcome: "needs_input",
535
- }),
540
+ }, cost),
536
541
  };
537
542
  }
538
543
  if (parsed.answer.choice === STOP_LABEL) {
@@ -541,7 +546,7 @@ function admit(state, resolved, stopped, input, spent) {
541
546
  code: "FLOW_BOUNDARY_DECLINED",
542
547
  action: "reanudá con 'aw flow advance' cuando quieras retomar esta frontera",
543
548
  outcome: "cancelled",
544
- }),
549
+ }, cost),
545
550
  };
546
551
  }
547
552
  // An execution result has to EARN the transition. Anything short of a completed
@@ -553,11 +558,49 @@ function admit(state, resolved, stopped, input, spent) {
553
558
  // would leave the transition pending for an effect nobody could produce.
554
559
  const verdict = executionVerdict(parsed.answer.result, resolved.action, effectsOfTransition(state, stopped));
555
560
  if (verdict !== null) {
556
- return { decision: reject(state, resolved, verdict.message, verdict.detail, spent) };
561
+ const trace = declaredTrace(stopped, resolved, parsed.answer, verdict);
562
+ return {
563
+ decision: reject(state, resolved, verdict.message, verdict.detail, {
564
+ ...cost,
565
+ ...(trace === null ? {} : { trace }),
566
+ }),
567
+ };
557
568
  }
558
569
  }
559
570
  return parsed;
560
571
  }
572
+ /**
573
+ * The material fact a REFUSED external result still leaves behind — or `null`.
574
+ *
575
+ * A rejected result is not the same as a result about nothing. The executor may
576
+ * have run the command, written the file and come back with evidence the
577
+ * boundary did not accept: the effect reached the world regardless of what the
578
+ * verdict decided about it. Nothing else in the run records that — the trace is
579
+ * only written by the internal driver — so an external execution could declare
580
+ * `applied` classes, be refused, exhaust the boundary, and then be handed back as
581
+ * answerable by a recovery whose whole job is to refuse exactly that.
582
+ *
583
+ * Recorded when the result declares an effect past `read_only`, and when the
584
+ * verdict is a PARTIAL one: "it finished and did not apply everything" is the
585
+ * case where what did reach the world is precisely the unknown.
586
+ */
587
+ function declaredTrace(stopped, resolved, answer, verdict) {
588
+ const applied = answer.result?.effects.applied ?? [];
589
+ if (!touchesTheWorld(applied) && verdict.detail.code !== "FLOW_EFFECT_PARTIAL")
590
+ return null;
591
+ const invocation = resolved.action?.invocation;
592
+ return {
593
+ kind: "failed",
594
+ transition: stopped.id,
595
+ // What was really run, named the way whoever ran it would recognize it: an
596
+ // external execution has no internal operation id to quote.
597
+ operation: invocation === undefined ? stopped.id : [invocation.program, ...invocation.args].join(" "),
598
+ code: verdict.detail.code,
599
+ message: verdict.message,
600
+ recovery: verdict.detail.action,
601
+ effects: [...applied],
602
+ };
603
+ }
561
604
  /**
562
605
  * Whether the sealed action changed underneath a run that is standing on it.
563
606
  *
@@ -566,7 +609,7 @@ function admit(state, resolved, stopped, input, spent) {
566
609
  * staleness the seal would otherwise report, because the fix is different: nothing
567
610
  * is wrong with the state, the invocation is simply no longer the one that ran.
568
611
  */
569
- function actionDrift(state, resolved) {
612
+ function actionDrift(state, resolved, cost) {
570
613
  const pending = state.pending_action;
571
614
  if (pending === null || resolved.action === null)
572
615
  return null;
@@ -575,7 +618,7 @@ function actionDrift(state, resolved) {
575
618
  return reject(state, resolved, "la acción de esta frontera cambió después de emitirse: el resultado corresponde a otra invocación", {
576
619
  code: "FLOW_ACTION_CHANGED",
577
620
  action: "volvé a correr 'aw flow advance' para recibir la acción vigente y ejecutá esa antes de responder",
578
- });
621
+ }, cost);
579
622
  }
580
623
  /**
581
624
  * Approving an effect is NOT deciding the step, and never executing it.
@@ -651,7 +694,8 @@ function applyAndAdvance(approved, journey, stopped, identity, answer) {
651
694
  * for the reason the registry gives everywhere it derives: two sources for one
652
695
  * fact disagree eventually, and then the state is unusable.
653
696
  */
654
- function resendCheck(state, resolved, identity) {
697
+ function resendCheck(state, resolved, cost) {
698
+ const identity = cost.identity;
655
699
  const ledger = new AttemptLedger();
656
700
  for (const past of state.attempts) {
657
701
  const replay = ledger.record(past);
@@ -677,24 +721,32 @@ function resendCheck(state, resolved, identity) {
677
721
  code: "FLOW_ANSWER_RESENT",
678
722
  action: "la frontera vigente es la que devuelve esta directiva; contestá esa",
679
723
  outcome: "completed",
680
- });
724
+ }, cost);
681
725
  }
682
726
  /**
683
727
  * A rejection, expressed as the recalculated directive.
684
728
  *
685
- * The state handed back is the one that was read: nothing is written on this
686
- * path, and the caller can see that because the mutation returns `ok: false`
687
- * only for invocation failures a business rejection returns the state unchanged
688
- * with its own outcome.
729
+ * Whether it spends an attempt is decided HERE, from the code, and never by the
730
+ * call site. It used to be the call site's business, and six refusals of this
731
+ * file simply forgot to pass the parameter: a scope answered with an alias the
732
+ * plan does not name never exhausted, never degraded and never became
733
+ * recoverable — an unbounded loop inside the very mechanism that exists to bound
734
+ * one. One place decides, one table says which codes count.
735
+ *
736
+ * `cost` is absent only where there is no boundary to charge: a run that already
737
+ * finished, or one that never stopped at anything.
689
738
  */
690
- function reject(state, resolved, message, detail, spent) {
739
+ function reject(state, resolved, message, detail, cost) {
691
740
  // A refused answer is the one rejection that CHANGES the run: the attempt is
692
741
  // recorded, so the boundary that has been tried to its cap degrades instead of
693
742
  // being emitted again. The boundary is recalculated over that state, which is
694
743
  // what turns the last refusal into the degradation rather than into an
695
744
  // identical question with a different error string.
696
- const after = spent === undefined ? state : withAttempt(state, spent.identity);
697
- const now = spent === undefined ? resolved : resolveBoundary(after, spent.journey);
745
+ const traced = cost?.trace === undefined || restatesLastEvent(state, cost.trace)
746
+ ? state
747
+ : withEvent(state, cost.trace);
748
+ const after = cost !== undefined && spendsAttempt(detail.code) ? withAttempt(traced, cost.identity) : traced;
749
+ const now = cost === undefined || after === state ? resolved : resolveBoundary(after, cost.journey);
698
750
  const built = directiveFor(after, now, [], {
699
751
  // No outcome named ⇒ the boundary decides it: a finished journey reports
700
752
  // `completed`, an open one `needs_input`. Hardcoding one here would let a
@@ -716,7 +768,10 @@ function reject(state, resolved, message, detail, spent) {
716
768
  ok: true,
717
769
  state: after,
718
770
  value: { directive, advanced: false },
719
- persist: spent !== undefined,
771
+ // Persisted exactly when something really changed, whichever half changed it.
772
+ // Tying this to the attempt alone would drop a material trace that a refusal
773
+ // produced without charging for it.
774
+ persist: after !== state,
720
775
  };
721
776
  }
722
777
  /**