@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.
- package/package.json +1 -1
- package/src/index.ts +83 -33
- package/src/wake.ts +20 -1
package/package.json
CHANGED
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
|
|
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
|
|
287
|
-
* follows the settlement which missed Pi's
|
|
288
|
-
* after intervening runs that also ended before
|
|
289
|
-
*
|
|
290
|
-
*
|
|
291
|
-
*
|
|
292
|
-
*
|
|
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 =
|
|
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,
|
|
956
|
-
*
|
|
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
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
)
|
|
985
|
-
//
|
|
986
|
-
//
|
|
987
|
-
//
|
|
988
|
-
//
|
|
989
|
-
|
|
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(/\
|
|
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
|
-
|
|
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;
|