@dorokuma/herdsman-pi 0.14.0 → 0.14.1

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 (3) hide show
  1. package/package.json +1 -1
  2. package/src/index.ts +83 -33
  3. package/src/wake.ts +20 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dorokuma/herdsman-pi",
3
- "version": "0.14.0",
3
+ "version": "0.14.1",
4
4
  "description": "Pi extension bridge for Herdsman agent history.",
5
5
  "type": "module",
6
6
  "keywords": [
package/src/index.ts CHANGED
@@ -141,8 +141,12 @@ type HerdsmanState = {
141
141
  * delivery from being written off, and it carries three guarantees at once:
142
142
  *
143
143
  * - never lost: while it is non-empty the settlement drives a continuation
144
- * (bounded by MAX_WAKE_CONTINUATION_ATTEMPTS) so a later run drains the queue
145
- * and carries the update out;
144
+ * (bounded by MAX_WAKE_CONTINUATION_ATTEMPTS drives) so a later run drains
145
+ * the queue and carries the update out. The bound is a real ceiling, not a
146
+ * suggestion: once it is spent the ids are released from here and
147
+ * acknowledged (`writeOffStrandedWakeDelivery`), because an update that no
148
+ * turn will ever carry out must not keep a run loop — and the daemon's
149
+ * redelivery of it — alive forever;
146
150
  * - never acknowledged unseen: these ids are excluded from the acknowledgement
147
151
  * path, so the daemon keeps them pending and redelivers them when this
148
152
  * session never consumes them (the only way a delivery Pi itself dropped —
@@ -281,17 +285,25 @@ export const WAKE_BUSY_SPIN_MS = 100;
281
285
  */
282
286
  export const WAKE_DEFERRED_TIMEOUT_MS = 5_000;
283
287
  /**
284
- * Upper bound on the continuation drives spent on one unconsumed delivery.
288
+ * Upper bound on the continuation drives spent on one unconsumed wake delivery.
285
289
  *
286
- * Every drive costs a full agent run, so three attempts cover the drive that
287
- * follows the settlement which missed Pi's follow-up queue plus two retries
288
- * after intervening runs that also ended before their stop point. Beyond that
289
- * the condition is systemic (Pi dropped the queue, the runs keep ending early)
290
- * and further attempts could only start runs without delivering anything: the
291
- * delivery stays unacknowledged, hence pending on the daemon, which redelivers
292
- * it to a later run or session.
290
+ * Every drive costs a full agent run, so the budget is deliberately small: three
291
+ * attempts already cover the drive that follows the settlement which missed Pi's
292
+ * follow-up queue plus two retries after intervening runs that also ended before
293
+ * their stop point, and five adds margin for a run that spent its stop point on
294
+ * a tool call. Beyond that the cause is systemic — the runs keep ending early, so
295
+ * starting another one could only produce another empty turn — and the delivery
296
+ * is written off instead (`writeOffStrandedWakeDelivery`): the ids leave
297
+ * `wakeAwaitingConsumption` and are acknowledged, which stops both the
298
+ * continuation loop and the daemon's redelivery of the same event.
299
+ *
300
+ * The trade-off is deliberate and asymmetric: losing one agent update is
301
+ * recoverable (the agent is still there and its transcript can be read
302
+ * directly), while an unbounded continuation loop floods the orchestrator
303
+ * session with empty turns and makes it unusable. A missed update is therefore
304
+ * strictly better than an endless one.
293
305
  */
294
- export const MAX_WAKE_CONTINUATION_ATTEMPTS = 3;
306
+ export const MAX_WAKE_CONTINUATION_ATTEMPTS = 5;
295
307
  /** `customType` of the hidden wake context this extension injects. */
296
308
  const WAKE_CONTEXT_CUSTOM_TYPE = "herdsman-wake-context";
297
309
  /** `customType` of the hidden marker that drives a missed wake continuation. */
@@ -935,6 +947,42 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
935
947
  return { deliveryQueue, confirmablePrefix, blockedByMissingTurn, stillAwaitingConsumption };
936
948
  };
937
949
 
950
+ const writeOffStrandedWakeDelivery = (eventIds: number[], ctx: PiContext): void => {
951
+ // Release first, acknowledge second: the release is what breaks the loop
952
+ // synchronously, while the acknowledgement is an RPC that may fail.
953
+ for (const eventId of eventIds) {
954
+ state.wakeAwaitingConsumption.delete(eventId);
955
+ // This set only ever holds the id of a delivery that was already handed
956
+ // to Pi, so releasing it is not permission to present it again: the
957
+ // session-wide presented guard stays behind and keeps a daemon replay
958
+ // of the same id out of the transcript.
959
+ state.presentedEventIds.add(eventId);
960
+ }
961
+ // Fresh budget for whatever is still awaiting consumption: the ids just
962
+ // written off are gone, and the ones that remain have not had their own
963
+ // MAX_WAKE_CONTINUATION_ATTEMPTS drives spent on them yet.
964
+ state.wakeContinuationAttempts = 0;
965
+ const stranded = eventIds
966
+ .map((eventId) => state.unackedDelivered.get(eventId))
967
+ .filter((event): event is AgentEventWireRecord => event !== undefined)
968
+ .sort((left, right) => left.id - right.id);
969
+ logHerdsmanPi(
970
+ "warn",
971
+ `[herdsman-pi] wake continuation gave up eventIds=${eventIds.join(",")} drives=${MAX_WAKE_CONTINUATION_ATTEMPTS} · released and acknowledged as consumed so the daemon stops redelivering them`,
972
+ );
973
+ ctx.ui.notify?.(
974
+ `Herdsman · ${eventIds.length} agent update${eventIds.length === 1 ? "" : "s"} could not be delivered by a wake turn · given up on (possibly never seen): read the agent directly for the details, or hand this workspace to another terminal so the daemon delivers it there`,
975
+ "warning",
976
+ );
977
+ if (stranded.length === 0) return;
978
+ void acknowledgeEventIds(stranded, { notify: false }, ctx).catch((error: unknown) => {
979
+ logHerdsmanPi(
980
+ "warn",
981
+ `[herdsman-pi] wake write-off acknowledgement failed eventIds=${stranded.map((event) => event.id).join(",")} · ${String(error)}`,
982
+ );
983
+ });
984
+ };
985
+
938
986
  /**
939
987
  * Drives one continuation that carries out a wake Pi has not drained.
940
988
  *
@@ -952,12 +1000,12 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
952
1000
  * ends before its stop point (error, user abort, a refused tool) leaves the
953
1001
  * next settlement driving again. That is bounded by
954
1002
  * MAX_WAKE_CONTINUATION_ATTEMPTS: starting runs cannot fix a cause that is
955
- * not about the queue, and once the budget is spent the delivery stays
956
- * unacknowledged and therefore pending on the daemon.
1003
+ * not about the queue, so once the budget is spent the delivery is written
1004
+ * off (released and acknowledged) instead of looping forever — see
1005
+ * `writeOffStrandedWakeDelivery` for the trade-off.
957
1006
  */
958
1007
  const driveWakeContinuation = (ctx: PiContext) => {
959
1008
  if (state.wakeAwaitingConsumption.size === 0) return;
960
- if (state.wakeContinuationAttempts >= MAX_WAKE_CONTINUATION_ATTEMPTS) return;
961
1009
  if (!pi.sendMessage) return;
962
1010
  const eventIds = [...state.wakeAwaitingConsumption].sort((left, right) => left - right);
963
1011
  state.wakeContinuationAttempts += 1;
@@ -978,23 +1026,15 @@ export function createHerdsmanPiExtension(options: ExtensionOptions = {}) {
978
1026
  logHerdsmanPi("warn", "[herdsman-pi] wake continuation refused by pi");
979
1027
  }
980
1028
  if (state.wakeContinuationAttempts >= MAX_WAKE_CONTINUATION_ATTEMPTS) {
981
- logHerdsmanPi(
982
- "warn",
983
- `[herdsman-pi] wake continuation limit reached awaiting=${eventIds.join(",")} attempts=${state.wakeContinuationAttempts} · delivery stays unacknowledged for daemon redelivery`,
984
- );
985
- // Bounded retries cannot deliver this update, and the daemon does not
986
- // redeliver while this terminal still holds the scope
987
- // (`nextDeliverableAfter` skips events already delivered to the same
988
- // owner), so the session is where it has to be visible. The notice must
989
- // not promise what the extension cannot do: a copy that Pi dropped cannot
990
- // be drained by "the next turn", it is simply gone here — so it says what
991
- // is true (unconfirmed, possibly dropped) and what the user can actually
992
- // do about it (a later turn still drains a queued copy, and handing the
993
- // workspace to another terminal makes the daemon redeliver the update).
994
- ctx.ui.notify?.(
995
- `Herdsman · ${eventIds.length} agent update${eventIds.length === 1 ? "" : "s"} could not be delivered by a wake turn · unconfirmed and possibly dropped: keep working here so a later turn drains a queued copy, or hand this workspace to another terminal so the daemon redelivers it`,
996
- "warning",
997
- );
1029
+ // This was the last drive the budget allows, and the queued copy is still
1030
+ // undrained: the delivery is written off instead of being left pending
1031
+ // forever. The ids are released from `wakeAwaitingConsumption` (no further
1032
+ // run is started for them) and acknowledged, which is what stops the
1033
+ // daemon from redelivering them every freshness window — the loop that
1034
+ // used to flood the orchestrator session until manual sqlite surgery.
1035
+ // Losing one update is the accepted cost; see
1036
+ // `writeOffStrandedWakeDelivery`.
1037
+ writeOffStrandedWakeDelivery([...state.wakeAwaitingConsumption], ctx);
998
1038
  }
999
1039
  };
1000
1040
 
@@ -2119,12 +2159,22 @@ function record(value: unknown): Record<string, unknown> {
2119
2159
  }
2120
2160
 
2121
2161
  function cleanContextText(value: string): string {
2162
+ // Same newline-preserving scheme as `normalizeExcerpt` in wake.ts: which byte a
2163
+ // line break is (CRLF, CR, U+2028/U+2029, NEL) is normalised to LF, trailing
2164
+ // whitespace is dropped per line, 3+ newlines collapse to a blank line, and
2165
+ // leading indentation plus inline runs of whitespace are kept as they are.
2166
+ // NEL (U+0085) is excluded from the control-character clearing regex below,
2167
+ // which would otherwise delete it as an unprintable byte; the rest of that
2168
+ // regex is left intact.
2122
2169
  return stripVTControlCharacters(value)
2123
2170
  .replace(
2124
- /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f\u00ad\u180e\u200b-\u200f\u202a-\u202e\u2060-\u2064\u2066-\u2069\ufeff]/g,
2171
+ /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u0084\u0086-\u009f\u00ad\u180e\u200b-\u200f\u202a-\u202e\u2060-\u2064\u2066-\u2069\ufeff]/g,
2125
2172
  "",
2126
2173
  )
2127
- .replace(/\s+/g, " ")
2174
+ .replace(/\r\n?/g, "\n")
2175
+ .replace(/[\u2028\u2029\u0085]/g, "\n")
2176
+ .replace(/[^\S\n]+$/gm, "")
2177
+ .replace(/\n{3,}/g, "\n\n")
2128
2178
  .trim();
2129
2179
  }
2130
2180
 
package/src/wake.ts CHANGED
@@ -34,7 +34,26 @@ function asRecord(value: unknown): Record<string, unknown> {
34
34
  function stringValue(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined; }
35
35
  function normalizeExcerpt(value: unknown): string {
36
36
  const raw = stringValue(value) ?? "";
37
- return stripVTControlCharacters(raw).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g, "").replace(/\s+/g, " ").trim();
37
+ // Line structure is evidence, not noise: a code block or a markdown heading
38
+ // only reads as one while its newlines survive, so this chain normalises
39
+ // *which byte* a line break is and where whitespace sits inside a line, and
40
+ // never folds a newline into a space:
41
+ // - CRLF/CR and the Unicode line separators (U+2028, U+2029, NEL) become LF,
42
+ // so "a line" means one thing everywhere downstream (NEL is excluded from
43
+ // the control-character regex, which would otherwise delete it);
44
+ // - trailing whitespace is dropped per line, so a "blank" line padded with
45
+ // spaces still collapses (it would otherwise defeat the 3+ newline rule);
46
+ // - three or more newlines collapse to a single blank line;
47
+ // - leading indentation and inline runs of whitespace are kept as they are,
48
+ // because they are what keeps a code block readable.
49
+ // No length cap is applied here.
50
+ return stripVTControlCharacters(raw)
51
+ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u0084\u0086-\u009f]/g, "")
52
+ .replace(/\r\n?/g, "\n")
53
+ .replace(/[\u2028\u2029\u0085]/g, "\n")
54
+ .replace(/[^\S\n]+$/gm, "")
55
+ .replace(/\n{3,}/g, "\n\n")
56
+ .trim();
38
57
  }
39
58
  function outcomeKind(event: AgentEventWireRecord): AgentOutcome["kind"] | undefined {
40
59
  if (!event.terminalId) return undefined;