@jeffober/thatch 0.2.0 → 0.2.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/artifacts/skills/thatch-fact-extractor.md +1 -1
- package/artifacts/skills/thatch-memory-verify.md +2 -2
- package/docs/dev/README.md +11 -7
- package/docs/dev/features/cross-session-chat.md +8 -1
- package/docs/dev/features/memory-store.md +1 -1
- package/docs/dev/features/nudge-pipeline.md +12 -0
- package/docs/dev/features/opencode-plugin.md +1 -1
- package/docs/dev/features/session-lifecycle.md +1 -1
- package/docs/dev/gotchas.md +58 -1
- package/docs/dev/setup-and-hooks.md +1 -1
- package/docs/user/README.md +1 -1
- package/docs/user/memory.md +1 -1
- package/package.json +1 -1
- package/src/chat.ts +17 -0
- package/src/db.ts +77 -12
- package/src/extraction.ts +89 -3
- package/src/index.ts +45 -0
- package/src/opencode/v2.ts +48 -4
- package/src/prompts.ts +104 -30
- package/src/runtime.ts +261 -72
- package/src/tool-defs.ts +156 -28
- package/src/watchers.ts +14 -0
package/src/runtime.ts
CHANGED
|
@@ -28,7 +28,7 @@ import { seedDefaultBehaviors } from "./seed-behaviors";
|
|
|
28
28
|
import { startVersionChecker, stopVersionChecker, getVersionChecker, readOnDiskVersion, compareSemver } from "./version-check";
|
|
29
29
|
import { WatcherRegistry, ghApiRun, ghAvailable, runWatchedCommand, watcherTarget, withCwdFallback, type Watcher } from "./watchers";
|
|
30
30
|
import { watcherNotificationNudge, watcherRearmNotice, watcherDeathNotice, chatNotificationNudge, chatEchoText, isChatEchoParts } from "./prompts";
|
|
31
|
-
import { ChatPoller, createWakeGate, hostedSessionIds, isDefaultSessionTitle } from "./chat";
|
|
31
|
+
import { ChatPoller, createWakeGate, hostedSessionIds, isDefaultSessionTitle, unexpiredGraceRehosts } from "./chat";
|
|
32
32
|
import { chatEnabled, chatAutoRegister, loadConfig, alertMode, notificationDefaults } from "./config";
|
|
33
33
|
import { sendNotification } from "./notify";
|
|
34
34
|
import { osProcessArgs, startupSessionId, continuesLastSessionFromArgv, continuesLastSessionId } from "./os-args";
|
|
@@ -51,6 +51,17 @@ const RECALL_THRESHOLD = parseFloat(process.env.THATCH_RECALL_THRESHOLD ?? "0.55
|
|
|
51
51
|
// returns to days later.
|
|
52
52
|
const DORMANT_WATCHER_GRACE_MS = 24 * 60 * 60 * 1000;
|
|
53
53
|
|
|
54
|
+
// Grace rehosting after a RESTART (see the hosted-row branch in the
|
|
55
|
+
// rehydration loop): the dead daemon's hosted set re-hosts for this window.
|
|
56
|
+
// Open TUI tabs reconnect to the new daemon but emit no events until the
|
|
57
|
+
// user types, so without the grace they go falsely stale ("presumed gone")
|
|
58
|
+
// while alive-idle, and mail stops waking them. Sessions that emit activity
|
|
59
|
+
// graduate to permanent hosting via the event stream; still-silent ones
|
|
60
|
+
// (closed tabs) age out at the grace and go stale again. Matches
|
|
61
|
+
// DORMANT_WATCHER_GRACE_MS - same resumability shape, same trade.
|
|
62
|
+
const CHAT_REHOST_GRACE_MS =
|
|
63
|
+
(Number(process.env.THATCH_CHAT_REHOST_GRACE_MINUTES ?? 0) * 60_000) || 24 * 60 * 60 * 1000;
|
|
64
|
+
|
|
54
65
|
/**
|
|
55
66
|
* True when every definition in a dormant watcher row is past its TTL plus
|
|
56
67
|
* the dormant grace period - the row is a corpse no resume can usefully
|
|
@@ -142,6 +153,14 @@ export async function createRuntime(input: {
|
|
|
142
153
|
const dbPath = process.env.THATCH_DB_PATH ?? join(configHome, "thatch", "thatch.db");
|
|
143
154
|
const modelName = process.env.THATCH_MODEL ?? "Xenova/bge-small-en-v1.5";
|
|
144
155
|
|
|
156
|
+
// Set by dispose(). Delayed async work (the child-reconcile timer, the
|
|
157
|
+
// extraction pipeline's queued finalization, queued event handlers) can
|
|
158
|
+
// fire after the db is closed; anything running past this flag must not
|
|
159
|
+
// touch the db - and journal writes must no-op BEFORE the lazy reopen
|
|
160
|
+
// accessor (src/db.ts) would hand them a fresh connection, because after
|
|
161
|
+
// a v2 plugin reload the RELOADED instance owns those rows.
|
|
162
|
+
let disposed = false;
|
|
163
|
+
|
|
145
164
|
// The DB must exist before identity detection: detectRepo consults the
|
|
146
165
|
// repo_paths cache when the session directory is gone.
|
|
147
166
|
const db = new ThatchDB(dbPath);
|
|
@@ -161,8 +180,13 @@ export async function createRuntime(input: {
|
|
|
161
180
|
const releaseModel = () => sharedModels.release(dbPath);
|
|
162
181
|
|
|
163
182
|
// Extraction buffer, journaled to runtime_state so a v2 plugin reload
|
|
164
|
-
// (same process, graph rebuilt) can rehydrate it.
|
|
183
|
+
// (same process, graph rebuilt) can rehydrate it. The journal callback
|
|
184
|
+
// checks the disposed flag FIRST: a finalization delayed past dispose
|
|
185
|
+
// (await points inside the pipeline) must not write through the lazy
|
|
186
|
+
// reopen accessor after the reload - the reloaded instance owns those
|
|
187
|
+
// rows. Same ownership rule as journalChild below.
|
|
165
188
|
const extraction = new ExtractionPipeline((kind, sessionID, value) => {
|
|
189
|
+
if (disposed) return;
|
|
166
190
|
if (value === undefined) db.runtimeStateDelete(kind, sessionID);
|
|
167
191
|
else db.runtimeStatePut(kind, sessionID, value, directory);
|
|
168
192
|
});
|
|
@@ -267,7 +291,13 @@ export async function createRuntime(input: {
|
|
|
267
291
|
const watchers = new WatcherRegistry({
|
|
268
292
|
// Journal watcher definitions so a v2 plugin reload (same process) can
|
|
269
293
|
// re-arm them; the registry journals after every membership change.
|
|
294
|
+
// Disposed check FIRST: a poll cycle suspended at an await can outlive
|
|
295
|
+
// watchers.dispose() and reach this callback after db.close(); with the
|
|
296
|
+
// lazy reopen it would otherwise hand a stale writer a fresh connection
|
|
297
|
+
// and resurrect an expired watcher after the reload. Same ownership rule
|
|
298
|
+
// as the extraction callback and journalChild.
|
|
270
299
|
journal: (sessionID, sessionWatchers) => {
|
|
300
|
+
if (disposed) return;
|
|
271
301
|
if (sessionWatchers.length === 0) db.runtimeStateDelete("watchers", sessionID);
|
|
272
302
|
else db.runtimeStatePut("watchers", sessionID, sessionWatchers, directory);
|
|
273
303
|
},
|
|
@@ -332,16 +362,27 @@ export async function createRuntime(input: {
|
|
|
332
362
|
const chatPoller = new ChatPoller({
|
|
333
363
|
store: db,
|
|
334
364
|
hostedSessions: () => {
|
|
365
|
+
const now = Date.now();
|
|
335
366
|
const hosted = hostedSessionIds({
|
|
336
367
|
statusKeys: sessionStatus.keys(),
|
|
337
368
|
resumedSessions: resumedSessions,
|
|
338
|
-
rehostedSessions: rehostedSessions,
|
|
369
|
+
rehostedSessions: [...rehostedSessions, ...unexpiredGraceRehosts(rehostGrace, now)],
|
|
339
370
|
exclude: childToParent.keys(),
|
|
340
371
|
});
|
|
341
|
-
//
|
|
342
|
-
//
|
|
343
|
-
//
|
|
344
|
-
|
|
372
|
+
// The journal NEVER SHRINKS within a process lifetime: a plugin
|
|
373
|
+
// reload rebuilds the hosted set empty (sessionStatus is in-memory),
|
|
374
|
+
// so a session that was idle across a reload would otherwise be
|
|
375
|
+
// dropped from the set and never re-hosted - falsely stale while its
|
|
376
|
+
// tab is open. The union means the next reload re-graces everything
|
|
377
|
+
// this process ever hosted (bounded by the 24h grace decay);
|
|
378
|
+
// shrinking happens only through session.deleted, which rewrites the
|
|
379
|
+
// row.
|
|
380
|
+
const prevRow = db
|
|
381
|
+
.runtimeStateAll()
|
|
382
|
+
.find((r) => r.kind === "hosted" && r.sessionID === directory);
|
|
383
|
+
const prevIds = prevRow && Array.isArray(prevRow.value) ? (prevRow.value as string[]) : [];
|
|
384
|
+
const merged = [...new Set([...prevIds, ...hosted])];
|
|
385
|
+
db.runtimeStatePut("hosted", directory, merged, directory);
|
|
345
386
|
return hosted;
|
|
346
387
|
},
|
|
347
388
|
deliver: async (sessionID, senders, count) => {
|
|
@@ -470,6 +511,16 @@ export async function createRuntime(input: {
|
|
|
470
511
|
// Populated from session.created events that carry a parentID.
|
|
471
512
|
const childToParent = new Map<string, string>();
|
|
472
513
|
|
|
514
|
+
// Per-child kind ("extraction" | "task"), recorded at session.created from
|
|
515
|
+
// the live extractionChildren set and at restore from the journal record.
|
|
516
|
+
// The parent-state mutations a child's lifecycle triggers (snapshot drain
|
|
517
|
+
// on memory write, whole-set requeue on error/deletion) are only correct
|
|
518
|
+
// for extraction children - a task sub-agent saving its own findings never
|
|
519
|
+
// processed the parent's buffer, and treating its lifecycle as extraction
|
|
520
|
+
// evidence silently dropped in-flight or pending entries (the v2
|
|
521
|
+
// all-children-linked loss vector, September 2026).
|
|
522
|
+
const childKinds = new Map<string, "extraction" | "task">();
|
|
523
|
+
|
|
473
524
|
// Snapshot of the parent's buffer at the moment each child was dispatched.
|
|
474
525
|
// When the child writes a memory, consumeSnapshot drains only these entries
|
|
475
526
|
// (by reference identity), preserving interleaved-turn entries that arrived
|
|
@@ -494,9 +545,10 @@ export async function createRuntime(input: {
|
|
|
494
545
|
// children from task-dispatched sub-agents (code review specialists, the
|
|
495
546
|
// nudge-path fact-extractor, any model-dispatched task). Only extraction
|
|
496
547
|
// children get the full cleanup: buffer drain, session deletion, toast.
|
|
497
|
-
// Non-extraction children get
|
|
498
|
-
//
|
|
499
|
-
// task tool that dispatched
|
|
548
|
+
// Non-extraction children get claim-scoped completion (a no-op for a
|
|
549
|
+
// child that never fetched a payload) + missedNudges.reset so their
|
|
550
|
+
// sessions are not deleted out from under the task tool that dispatched
|
|
551
|
+
// them.
|
|
500
552
|
const extractionChildren = new Set<string>();
|
|
501
553
|
|
|
502
554
|
// Per-child-session extraction metrics for the toast notification. When the
|
|
@@ -507,14 +559,21 @@ export async function createRuntime(input: {
|
|
|
507
559
|
|
|
508
560
|
// Journal the child bookkeeping (parent link, buffer snapshot, metrics) so
|
|
509
561
|
// a v2 plugin reload can rehydrate an in-flight extraction instead of
|
|
510
|
-
// orphaning it. A child with no parent entry journals a delete.
|
|
562
|
+
// orphaning it. A child with no parent entry journals a delete. The kind
|
|
563
|
+
// marker distinguishes extraction children from task-dispatched sub-agents
|
|
564
|
+
// (the live source of truth is the extractionChildren set): restoring a
|
|
565
|
+
// task sub-agent as an extraction child let its idle take the extraction
|
|
566
|
+
// branch - consumeSnapshot silently dropped the parent's buffered entries
|
|
567
|
+
// without extraction, and the task tool's session got a delete call.
|
|
511
568
|
const journalChild = (childId: string) => {
|
|
569
|
+
if (disposed) return;
|
|
512
570
|
const parentID = childToParent.get(childId);
|
|
513
571
|
if (!parentID) {
|
|
514
572
|
db.runtimeStateDelete("child", childId);
|
|
515
573
|
return;
|
|
516
574
|
}
|
|
517
575
|
db.runtimeStatePut("child", childId, {
|
|
576
|
+
kind: extractionChildren.has(childId) ? "extraction" : "task",
|
|
518
577
|
parentID,
|
|
519
578
|
snapshot: parentSnapshots.get(childId) ?? [],
|
|
520
579
|
metrics: childMetrics.get(childId) ?? { new: 0, updated: 0, deleted: 0 },
|
|
@@ -551,6 +610,11 @@ export async function createRuntime(input: {
|
|
|
551
610
|
// (same pid + same directory). Written through by the poller's hosted-set
|
|
552
611
|
// read, so the next reload picks up the latest set.
|
|
553
612
|
const rehostedSessions = new Set<string>();
|
|
613
|
+
// Foreign-pid hosted rows (restart): the dead daemon's hosted set,
|
|
614
|
+
// re-hosted under a grace window instead of dropped - open TUI tabs
|
|
615
|
+
// reconnect but emit no events until the user types. id -> grace
|
|
616
|
+
// deadline (epoch ms).
|
|
617
|
+
const rehostGrace = new Map<string, number>();
|
|
554
618
|
const rehydrated: Record<string, number> = {};
|
|
555
619
|
for (const row of db.runtimeStateAll()) {
|
|
556
620
|
const samePid = row.pid === process.pid;
|
|
@@ -569,6 +633,21 @@ export async function createRuntime(input: {
|
|
|
569
633
|
if (row.kind === "watchers" && !dormantWatchersStale((row.value as Watcher[] | null) ?? [])) {
|
|
570
634
|
continue;
|
|
571
635
|
}
|
|
636
|
+
// A foreign-pid HOSTED row re-hosts under a grace window instead of
|
|
637
|
+
// being pruned: open TUI tabs reconnect to the new daemon but emit
|
|
638
|
+
// no events until the user types, so dropping the set made them
|
|
639
|
+
// falsely stale ("presumed gone") while alive-idle, and mail to
|
|
640
|
+
// them stopped waking the tab. Sessions that emit activity graduate
|
|
641
|
+
// to permanent hosting via the event stream; still-silent ones
|
|
642
|
+
// (closed tabs) age out at the grace and go stale. The row itself
|
|
643
|
+
// stays: the poller's next hosted-set journal rewrites it, and a
|
|
644
|
+
// further restart re-graces whatever it then holds.
|
|
645
|
+
if (row.kind === "hosted") {
|
|
646
|
+
for (const id of (row.value ?? []) as string[]) {
|
|
647
|
+
rehostGrace.set(id, Date.now() + CHAT_REHOST_GRACE_MS);
|
|
648
|
+
}
|
|
649
|
+
continue;
|
|
650
|
+
}
|
|
572
651
|
db.runtimeStateDelete(row.kind, row.sessionID);
|
|
573
652
|
continue;
|
|
574
653
|
}
|
|
@@ -586,26 +665,32 @@ export async function createRuntime(input: {
|
|
|
586
665
|
} else if (row.kind === "watchers") {
|
|
587
666
|
watchers.hydrate((row.value ?? []) as Watcher[]);
|
|
588
667
|
} else if (row.kind === "hosted") {
|
|
589
|
-
//
|
|
590
|
-
//
|
|
591
|
-
//
|
|
592
|
-
// event stream instead.
|
|
593
|
-
if (!samePid) {
|
|
594
|
-
db.runtimeStateDelete(row.kind, row.sessionID);
|
|
595
|
-
continue;
|
|
596
|
-
}
|
|
668
|
+
// Same-pid reload: re-host permanently (the plugin re-ran in place;
|
|
669
|
+
// the sessions may still be running). Foreign-pid rows were graced
|
|
670
|
+
// in the restart branch above.
|
|
597
671
|
for (const id of (row.value ?? []) as string[]) rehostedSessions.add(id);
|
|
598
672
|
} else if (row.kind === "child") {
|
|
599
|
-
const rec = row.value as { parentID?: string; snapshot?: ToolInteraction[]; metrics?: { new: number; updated: number; deleted: number } };
|
|
673
|
+
const rec = row.value as { kind?: "extraction" | "task"; parentID?: string; snapshot?: ToolInteraction[]; metrics?: { new: number; updated: number; deleted: number } };
|
|
600
674
|
if (samePid && rec?.parentID) {
|
|
601
675
|
// Reload: the child may still be running - restore its live
|
|
602
|
-
// bookkeeping so its idle event finds the maps populated.
|
|
676
|
+
// bookkeeping so its idle event finds the maps populated. The
|
|
677
|
+
// default covers rows written before the marker existed; it errs
|
|
678
|
+
// toward extraction (defaulting to task would strand a legacy
|
|
679
|
+
// in-flight extraction child with extracting never restored,
|
|
680
|
+
// suppressing both extraction paths for the session's life), at
|
|
681
|
+
// the cost of resurrecting the restore-time misclassification for
|
|
682
|
+
// the rarer pre-upgrade in-flight task child - a one-window trade,
|
|
683
|
+
// self-healing as rows are rewritten with kinds.
|
|
684
|
+
const kind = rec.kind ?? "extraction";
|
|
685
|
+
childKinds.set(row.sessionID, kind);
|
|
603
686
|
childToParent.set(row.sessionID, rec.parentID);
|
|
604
687
|
parentSnapshots.set(row.sessionID, rec.snapshot ?? []);
|
|
605
|
-
extractionChildren.add(row.sessionID);
|
|
606
|
-
extracting.add(rec.parentID);
|
|
607
688
|
if (rec.metrics) childMetrics.set(row.sessionID, rec.metrics);
|
|
608
|
-
|
|
689
|
+
if (kind === "extraction") {
|
|
690
|
+
extractionChildren.add(row.sessionID);
|
|
691
|
+
extracting.add(rec.parentID);
|
|
692
|
+
restoredChildren.set(row.sessionID, rec.parentID);
|
|
693
|
+
}
|
|
609
694
|
} else {
|
|
610
695
|
// Restart: the child ran in the dead process and no execution event
|
|
611
696
|
// will ever arrive for it. Recover the snapshot as plain pending
|
|
@@ -755,17 +840,14 @@ export async function createRuntime(input: {
|
|
|
755
840
|
// the reload window.
|
|
756
841
|
const finishExtractionChild = async (sessionID: string, parentID: string) => {
|
|
757
842
|
if (extractionChildren.has(sessionID)) {
|
|
758
|
-
//
|
|
759
|
-
// child
|
|
760
|
-
//
|
|
761
|
-
//
|
|
762
|
-
//
|
|
763
|
-
//
|
|
764
|
-
//
|
|
765
|
-
|
|
766
|
-
if (snapshot) {
|
|
767
|
-
extraction.consumeSnapshot(parentID, snapshot);
|
|
768
|
-
}
|
|
843
|
+
// The child's claimed delivery is consumed by completeClaimed below -
|
|
844
|
+
// the fetch recorded exactly what the child received, so nothing else
|
|
845
|
+
// is dropped here. Entries the child never received (post-fetch
|
|
846
|
+
// arrivals, or a never-fetched payload) stay pending and re-nudge;
|
|
847
|
+
// the old snapshot drain here would have silently dropped the
|
|
848
|
+
// never-fetched case without extraction. (Snapshot drain removed
|
|
849
|
+
// September 2026 - claim semantics subsume it. The journal's
|
|
850
|
+
// snapshot field remains for restart recovery.)
|
|
769
851
|
|
|
770
852
|
// Fire a toast with the extraction metrics. Only show a toast when
|
|
771
853
|
// memories were actually written - no toast for no-save runs to avoid
|
|
@@ -789,9 +871,14 @@ export async function createRuntime(input: {
|
|
|
789
871
|
}
|
|
790
872
|
}
|
|
791
873
|
|
|
792
|
-
|
|
874
|
+
// Scope the completion to THIS child's claimed delivery. A fetcher
|
|
875
|
+
// that never fetched (a sibling sub-agent going idle, an extractor
|
|
876
|
+
// that crashed before its payload call) has no claim and must not
|
|
877
|
+
// drop the accepted set - another extractor may hold it in flight.
|
|
878
|
+
extraction.completeClaimed(sessionID);
|
|
793
879
|
missedNudges.delete(parentID);
|
|
794
880
|
extracting.delete(parentID);
|
|
881
|
+
childKinds.delete(sessionID);
|
|
795
882
|
childToParent.delete(sessionID);
|
|
796
883
|
parentSnapshots.delete(sessionID);
|
|
797
884
|
childMetrics.delete(sessionID);
|
|
@@ -805,10 +892,13 @@ export async function createRuntime(input: {
|
|
|
805
892
|
// Best-effort - the child is idle and harmless if not deleted.
|
|
806
893
|
}
|
|
807
894
|
} else {
|
|
808
|
-
// Task-dispatched sub-agent went idle. Complete
|
|
809
|
-
//
|
|
810
|
-
//
|
|
811
|
-
|
|
895
|
+
// Task-dispatched sub-agent went idle. Complete only what THIS child
|
|
896
|
+
// claimed via its payload fetch - sibling specialists going idle have
|
|
897
|
+
// no claim and must be a no-op, or their idle signal drops another
|
|
898
|
+
// extractor's in-flight accepted payload. An extractor that never
|
|
899
|
+
// fetched leaves its entries held; requeueStaleAccepted bounds the
|
|
900
|
+
// linger.
|
|
901
|
+
extraction.completeClaimed(sessionID);
|
|
812
902
|
missedNudges.delete(parentID);
|
|
813
903
|
}
|
|
814
904
|
};
|
|
@@ -826,6 +916,10 @@ export async function createRuntime(input: {
|
|
|
826
916
|
const t = setTimeout(resolve, 10_000);
|
|
827
917
|
t.unref?.();
|
|
828
918
|
});
|
|
919
|
+
// Dispose (a v2 plugin reload or process exit) may have closed the db
|
|
920
|
+
// during the 10s window; finalizing into it would throw on the journal
|
|
921
|
+
// write. The reloaded instance's own reconciler owns the rows now.
|
|
922
|
+
if (disposed) return;
|
|
829
923
|
let live: Record<string, { type: string }> = {};
|
|
830
924
|
try {
|
|
831
925
|
live = (await caps.fetchStatuses()) ?? {};
|
|
@@ -915,6 +1009,7 @@ export async function createRuntime(input: {
|
|
|
915
1009
|
const cleanupChild = () => {
|
|
916
1010
|
extracting.delete(parentID);
|
|
917
1011
|
extractionChildren.delete(childId);
|
|
1012
|
+
childKinds.delete(childId);
|
|
918
1013
|
childToParent.delete(childId);
|
|
919
1014
|
parentSnapshots.delete(childId);
|
|
920
1015
|
childMetrics.delete(childId);
|
|
@@ -966,6 +1061,10 @@ export async function createRuntime(input: {
|
|
|
966
1061
|
const toolMemoryRemember = async (sessionID: string, overwrite: unknown) => {
|
|
967
1062
|
extraction.consume(sessionID);
|
|
968
1063
|
missedNudges.delete(sessionID);
|
|
1064
|
+
// A session that fetched its own payload (direct parent-side fetch or a
|
|
1065
|
+
// child's payload call) completes its claim when it writes a memory -
|
|
1066
|
+
// the write proves the delivered entries were processed.
|
|
1067
|
+
extraction.completeClaimed(sessionID);
|
|
969
1068
|
// Track extraction metrics for toast display. Counted for any
|
|
970
1069
|
// child session (both direct-extraction and nudge-path sub-agents)
|
|
971
1070
|
// since childToParent covers both. The toast only fires for
|
|
@@ -978,18 +1077,18 @@ export async function createRuntime(input: {
|
|
|
978
1077
|
childMetrics.set(sessionID, metrics);
|
|
979
1078
|
journalChild(sessionID);
|
|
980
1079
|
// Complete the parent's accepted entries (the extractor confirmed
|
|
981
|
-
// it is alive and saving)
|
|
982
|
-
//
|
|
983
|
-
//
|
|
984
|
-
//
|
|
985
|
-
//
|
|
986
|
-
//
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
1080
|
+
// it is alive and saving). Completion is scoped to THIS child's
|
|
1081
|
+
// claimed delivery - the fetch IS the delivery record, so whatever
|
|
1082
|
+
// the child received is consumed here and nothing else. Pending
|
|
1083
|
+
// entries the child never received (post-fetch arrivals, or a child
|
|
1084
|
+
// that never fetched) stay pending and re-nudge: no silent loss.
|
|
1085
|
+
// (The old dispatch-time snapshot drain was either a no-op under
|
|
1086
|
+
// claim semantics or a silent-loss vector for children that never
|
|
1087
|
+
// fetched - removed September 2026. The journal's snapshot field
|
|
1088
|
+
// remains for restart recovery.)
|
|
1089
|
+
extraction.completeClaimed(sessionID);
|
|
1090
|
+
journalChild(sessionID);
|
|
1091
|
+
missedNudges.delete(parentID);
|
|
993
1092
|
journalChild(sessionID);
|
|
994
1093
|
missedNudges.delete(parentID);
|
|
995
1094
|
}
|
|
@@ -997,7 +1096,14 @@ export async function createRuntime(input: {
|
|
|
997
1096
|
const toolExtractionDone = (sessionID: string, targetID?: string) => {
|
|
998
1097
|
const parentID = childToParent.get(sessionID);
|
|
999
1098
|
if (parentID) {
|
|
1000
|
-
|
|
1099
|
+
// The child's completion consumes its own claimed delivery. The
|
|
1100
|
+
// whole-set fallback covers a child that acks without ever fetching
|
|
1101
|
+
// (its payload call errored) - rare, and the ack is a deliberate
|
|
1102
|
+
// processed signal, unlike the automatic idle path which is
|
|
1103
|
+
// claim-scoped only.
|
|
1104
|
+
if (!extraction.completeClaimed(sessionID)) {
|
|
1105
|
+
extraction.completeAccepted(parentID);
|
|
1106
|
+
}
|
|
1001
1107
|
missedNudges.delete(parentID);
|
|
1002
1108
|
extraction.consume(sessionID);
|
|
1003
1109
|
} else if (targetID && targetID !== sessionID) {
|
|
@@ -1007,8 +1113,13 @@ export async function createRuntime(input: {
|
|
|
1007
1113
|
// relationship and the explicit session_id is the only signal that
|
|
1008
1114
|
// the target's accepted entries are accounted for. Without this
|
|
1009
1115
|
// branch the accepted queue lingers forever and a tool-dense session
|
|
1010
|
-
// re-nudges every idle on never-draining exhaust.
|
|
1011
|
-
|
|
1116
|
+
// re-nudges every idle on never-draining exhaust. Completion is
|
|
1117
|
+
// scoped to the child's claimed delivery; the whole-set fallback
|
|
1118
|
+
// covers the no-claim case (a fetch that errored, or the MCP path
|
|
1119
|
+
// where the file-backed queue was drained instead).
|
|
1120
|
+
if (!extraction.completeClaimed(sessionID)) {
|
|
1121
|
+
extraction.completeAccepted(targetID);
|
|
1122
|
+
}
|
|
1012
1123
|
missedNudges.delete(targetID);
|
|
1013
1124
|
} else {
|
|
1014
1125
|
extraction.accept(sessionID);
|
|
@@ -1023,10 +1134,15 @@ export async function createRuntime(input: {
|
|
|
1023
1134
|
|
|
1024
1135
|
return {
|
|
1025
1136
|
coreContext: buildCoreContext(db, model, repo, {
|
|
1026
|
-
extractionPayloadProvider: (
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1137
|
+
extractionPayloadProvider: (targetID: string, fetcherID?: string): string | null => {
|
|
1138
|
+
// A fetcher identity turns the fetch into the delivery record: the
|
|
1139
|
+
// claimed entries stay accepted, and THAT fetcher's completion
|
|
1140
|
+
// signal consumes only them (claim semantics). Without a fetcher
|
|
1141
|
+
// (no host session context) the fetch is a pure peek - legacy
|
|
1142
|
+
// behavior.
|
|
1143
|
+
const all = fetcherID
|
|
1144
|
+
? extraction.claim(targetID, fetcherID)
|
|
1145
|
+
: [...extraction.peekAccepted(targetID), ...extraction.peek(targetID)];
|
|
1030
1146
|
if (all.length === 0) return null;
|
|
1031
1147
|
return extraction.buildPayload(all, repo);
|
|
1032
1148
|
},
|
|
@@ -1076,6 +1192,13 @@ export async function createRuntime(input: {
|
|
|
1076
1192
|
// extraction_done call in a child session of that parent (the
|
|
1077
1193
|
// extractor confirming it processed them), or the child going idle
|
|
1078
1194
|
// (a no-save run that never acks still counts as processed).
|
|
1195
|
+
// Completion is CLAIM-SCOPED: the pipeline records which fetcher
|
|
1196
|
+
// received the payload (get_extraction_payload), and a completion
|
|
1197
|
+
// signal consumes only that fetcher's delivery. A sibling
|
|
1198
|
+
// sub-agent going idle - a code-review specialist, a slow child
|
|
1199
|
+
// from a previous round - has no claim and is a no-op, so it can
|
|
1200
|
+
// no longer silently drop another extractor's in-flight payload
|
|
1201
|
+
// (the accept-then-racing-completion loss, fixed September 2026).
|
|
1079
1202
|
// - REQUEUE returns the held entries to pending. Signals: the child
|
|
1080
1203
|
// session errors or is deleted before completing.
|
|
1081
1204
|
//
|
|
@@ -1219,7 +1342,12 @@ export async function createRuntime(input: {
|
|
|
1219
1342
|
if (wrapUp) {
|
|
1220
1343
|
pendingWrapUp.set(input.sessionID, wrapUp);
|
|
1221
1344
|
// Journaled so a v2 plugin reload before the session's next idle
|
|
1222
|
-
// does not silently drop the armed wrap-up.
|
|
1345
|
+
// does not silently drop the armed wrap-up. Disposed check FIRST:
|
|
1346
|
+
// a command execute still in flight when the reload's cleanup runs
|
|
1347
|
+
// must not land a stale wrap-up row through the lazy reopen (the
|
|
1348
|
+
// reloaded instance owns the journal rows; same rule as the
|
|
1349
|
+
// extraction and watcher journal callbacks).
|
|
1350
|
+
if (disposed) return;
|
|
1223
1351
|
db.runtimeStatePut("wrapup", input.sessionID, wrapUp, directory);
|
|
1224
1352
|
}
|
|
1225
1353
|
},
|
|
@@ -1352,6 +1480,13 @@ export async function createRuntime(input: {
|
|
|
1352
1480
|
// requeue here so they are honestly re-extracted instead of lingering
|
|
1353
1481
|
// as silent loss.
|
|
1354
1482
|
extraction.requeueStaleAccepted();
|
|
1483
|
+
// Task-dispatched sub-agent sessions may have restricted tool lists
|
|
1484
|
+
// that exclude the thatch tools, and even where the tools exist the
|
|
1485
|
+
// child is driven by its dispatch prompt, so nudges only burn "No
|
|
1486
|
+
// tool named ..." error rounds or add noise there. Extraction
|
|
1487
|
+
// children are excluded: they run with full tools and are driven by
|
|
1488
|
+
// extractionDirectPrompt.
|
|
1489
|
+
if (childToParent.has(input.sessionID) && !extractionChildren.has(input.sessionID)) return;
|
|
1355
1490
|
if (!extracting.has(input.sessionID) && extraction.pending(input.sessionID)) {
|
|
1356
1491
|
const batch = extraction.peek(input.sessionID);
|
|
1357
1492
|
const missed = missedNudges.get(input.sessionID) ?? 0;
|
|
@@ -1511,6 +1646,7 @@ export async function createRuntime(input: {
|
|
|
1511
1646
|
const info = event.properties.info;
|
|
1512
1647
|
if (info.parentID) {
|
|
1513
1648
|
childToParent.set(info.id, info.parentID);
|
|
1649
|
+
childKinds.set(info.id, extractionChildren.has(info.id) ? "extraction" : "task");
|
|
1514
1650
|
parentSnapshots.set(info.id, [...extraction.peek(info.parentID)]);
|
|
1515
1651
|
journalChild(info.id);
|
|
1516
1652
|
// Child sessions don't need the session-start reminder - only
|
|
@@ -1533,8 +1669,17 @@ export async function createRuntime(input: {
|
|
|
1533
1669
|
}
|
|
1534
1670
|
const parentID = childID ? childToParent.get(childID) : undefined;
|
|
1535
1671
|
if (parentID && childID) {
|
|
1536
|
-
|
|
1672
|
+
// Requeue what THIS child actually held: its claimed delivery if
|
|
1673
|
+
// it fetched. ERROR is a terminal death signal, so a claim-less
|
|
1674
|
+
// child (died before fetching - boot failures land here) falls
|
|
1675
|
+
// back to whole-set requeue for immediate recovery, regardless
|
|
1676
|
+
// of kind: the worst case is a benign duplicate extraction, never
|
|
1677
|
+
// silent loss.
|
|
1678
|
+
if (!extraction.requeueClaimed(childID)) {
|
|
1679
|
+
extraction.requeueAccepted(parentID);
|
|
1680
|
+
}
|
|
1537
1681
|
extracting.delete(parentID);
|
|
1682
|
+
childKinds.delete(childID);
|
|
1538
1683
|
childToParent.delete(childID);
|
|
1539
1684
|
parentSnapshots.delete(childID);
|
|
1540
1685
|
childMetrics.delete(childID);
|
|
@@ -1647,8 +1792,18 @@ export async function createRuntime(input: {
|
|
|
1647
1792
|
// the topic column instead (refreshed on every idle), so the name
|
|
1648
1793
|
// and the description stay separate identities. Fire-and-forget: a
|
|
1649
1794
|
// failed registration or title fetch must never block event
|
|
1650
|
-
// delivery.
|
|
1651
|
-
|
|
1795
|
+
// delivery. TOP-LEVEL ONLY: child sessions (extraction sub-agents,
|
|
1796
|
+
// task sub-agents) are machinery, not chat participants - without
|
|
1797
|
+
// this check every fact-extractor run registered a roster row
|
|
1798
|
+
// (the "thatch-extraction" corpse flood, September 2026). The
|
|
1799
|
+
// prompt-register path above has the same check.
|
|
1800
|
+
if (
|
|
1801
|
+
chatOn &&
|
|
1802
|
+
chatAutoRegister(loadConfig(dbPath).config) &&
|
|
1803
|
+
sessionID &&
|
|
1804
|
+
!childToParent.has(sessionID) &&
|
|
1805
|
+
!db.hasChatLeaveTombstone(sessionID)
|
|
1806
|
+
) {
|
|
1652
1807
|
void (async () => {
|
|
1653
1808
|
try {
|
|
1654
1809
|
const data = await caps.sessionGet(sessionID);
|
|
@@ -1730,13 +1885,41 @@ export async function createRuntime(input: {
|
|
|
1730
1885
|
// ChatStore.unregister), and the tombstone is cleared only by an
|
|
1731
1886
|
// explicit chat_register rejoin, so a genuinely new session is
|
|
1732
1887
|
// unaffected.
|
|
1733
|
-
// A child deleted before completing never processed its payload
|
|
1888
|
+
// A child deleted before completing never processed its payload -
|
|
1889
|
+
// unless it was a task-kind child: task sub-agents finish and get
|
|
1890
|
+
// deleted ROUTINELY, and a finished task child's later deletion
|
|
1891
|
+
// must not requeue the parent's extraction state (it would yank an
|
|
1892
|
+
// in-flight extractor's accepted set back to pending for duplicate
|
|
1893
|
+
// extraction). Requeue what the child actually held: its claim, or
|
|
1894
|
+
// for a claim-less extraction child the whole accepted set.
|
|
1734
1895
|
const parentID = childToParent.get(id);
|
|
1896
|
+
const childKind = childKinds.get(id) ?? "extraction";
|
|
1735
1897
|
if (parentID) {
|
|
1736
|
-
extraction.
|
|
1737
|
-
|
|
1898
|
+
if (!extraction.requeueClaimed(id) && childKind === "extraction") {
|
|
1899
|
+
extraction.requeueAccepted(parentID);
|
|
1900
|
+
}
|
|
1901
|
+
if (childKind === "extraction") extracting.delete(parentID);
|
|
1738
1902
|
}
|
|
1903
|
+
extraction.dropClaim(id);
|
|
1904
|
+
childKinds.delete(id);
|
|
1739
1905
|
childToParent.delete(id);
|
|
1906
|
+
// A deleted session's grace rehost ends immediately - the user
|
|
1907
|
+
// closed it deliberately; the roster must not keep it fresh. The
|
|
1908
|
+
// hosted-row journal is pruned too: the union never shrinks on its
|
|
1909
|
+
// own, and a deleted session must not re-host into the grace on
|
|
1910
|
+
// the next reload.
|
|
1911
|
+
rehostGrace.delete(id);
|
|
1912
|
+
const hostedRow = db
|
|
1913
|
+
.runtimeStateAll()
|
|
1914
|
+
.find((r) => r.kind === "hosted" && r.sessionID === directory);
|
|
1915
|
+
if (hostedRow && Array.isArray(hostedRow.value)) {
|
|
1916
|
+
db.runtimeStatePut(
|
|
1917
|
+
"hosted",
|
|
1918
|
+
directory,
|
|
1919
|
+
(hostedRow.value as string[]).filter((sid) => sid !== id),
|
|
1920
|
+
directory,
|
|
1921
|
+
);
|
|
1922
|
+
}
|
|
1740
1923
|
parentSnapshots.delete(id);
|
|
1741
1924
|
childMetrics.delete(id);
|
|
1742
1925
|
extractionChildren.delete(id);
|
|
@@ -1747,6 +1930,7 @@ export async function createRuntime(input: {
|
|
|
1747
1930
|
// watchers die with it - the session that would receive their
|
|
1748
1931
|
// notifications no longer exists.
|
|
1749
1932
|
extraction.completeAccepted(id);
|
|
1933
|
+
extraction.dropClaim(id);
|
|
1750
1934
|
extracting.delete(id);
|
|
1751
1935
|
watchers.cancelSession(id);
|
|
1752
1936
|
// Leaving the chat directory is the graceful-exit fast path; a
|
|
@@ -1801,15 +1985,20 @@ export async function createRuntime(input: {
|
|
|
1801
1985
|
},
|
|
1802
1986
|
|
|
1803
1987
|
dispose: async () => {
|
|
1988
|
+
disposed = true;
|
|
1804
1989
|
stopVersionChecker();
|
|
1805
1990
|
watchers.dispose();
|
|
1806
1991
|
chatPoller.dispose();
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1992
|
+
try {
|
|
1993
|
+
await releaseModel();
|
|
1994
|
+
} finally {
|
|
1995
|
+
// In finally so a releaseModel rejection cannot leak the connection.
|
|
1996
|
+
// Note: the runtime_state journal is deliberately NOT cleared here.
|
|
1997
|
+
// Disposal happens before every v2 plugin reload, and the reloaded
|
|
1998
|
+
// setup() rehydrates from it; stale rows from dead processes are
|
|
1999
|
+
// pruned by the setup-time partition instead.
|
|
2000
|
+
db.close();
|
|
2001
|
+
}
|
|
1813
2002
|
},
|
|
1814
2003
|
};
|
|
1815
2004
|
}
|