@try-works/dsh-memory 0.1.20 → 0.1.22
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/STATUS.md +8 -0
- package/package.json +1 -1
- package/scripts/inspect-log.ts +40 -0
- package/scripts/repair-ignorable.ts +130 -0
- package/scripts/scan-sessions.ts +43 -0
- package/scripts/verify-log.ts +26 -0
- package/src/host.js +46 -7
package/STATUS.md
CHANGED
|
@@ -61,6 +61,7 @@ Observed in the running host. Each row names the effect that was seen, not a cla
|
|
|
61
61
|
| Injection metric counts injections | `events.inject` advances by 1 per injection; the old character total is preserved under `legacy.inject` |
|
|
62
62
|
| `memory_recover` is wired | calling it no longer answers "unavailable in this context"; it reaches output validation |
|
|
63
63
|
| Session logs are searchable in principle | zstd, not encrypted; 368 logs, 740MB compressed, ~2.96GB plaintext |
|
|
64
|
+
| `memory/map` events carry `ignorable: true` | the live session log's own event is marked, and both dsh-memory logs pass the harness's `validateStoredEvents` — the exact check that refused session-cc4795a7 with "unknown to this harness and not marked ignorable" |
|
|
64
65
|
|
|
65
66
|
## WRITTEN but NOT LOADED
|
|
66
67
|
|
|
@@ -71,6 +72,7 @@ Implemented and tested on disk. The running host predates them, so none of this
|
|
|
71
72
|
| `due()` priority ordering | retry → abandon → work → recover, so a bounded batch reaches a due retry | measured: the retry sat at position 101 of 111 and is now position 1 |
|
|
72
73
|
| `hostExtras(host)` as one source | `index.js` and the wiring test shared a copied list that drifted | the test now derives extras from the host object |
|
|
73
74
|
| `memory_recover` report omits its `entries` field rather than blanking it | the old line added `entries: undefined`, which is NOT valid JSON, so the harness rejected the whole result | the new guard round-trips every tool output; reintroducing it fails with `value.entries is undefined` |
|
|
75
|
+
| Publisher stops when the harness does not stamp `ignorable` (0.1.21) | ≤0.1.19 appended `memory/map` WITHOUT the marker into ten real session logs, so every later load of those sessions failed ("unknown to this harness and not marked ignorable"). The guard checks the RETURN VALUE of `session.append` and suppresses publishing for that session at the first refusal, instead of poisoning the log | 403 tests pass; the new test drives a harness-shaped stub that returns an unstamped event and asserts the next turn appends nothing, even with a changed store and a passed throttle |
|
|
74
76
|
|
|
75
77
|
How to tell loaded from not: ask the host. `memory_recover` used to reply *"unavailable in this
|
|
76
78
|
context"* and now replies *"returned invalid output: value is not lossless JSON"*. The change of
|
|
@@ -145,6 +147,12 @@ the flag on did not change what the user sees.
|
|
|
145
147
|
|
|
146
148
|
### Blocked on a restart
|
|
147
149
|
|
|
150
|
+
- [ ] **Restart to load 0.1.21.** Published and installed: 0.1.21 is on npm (tarball byte-identical to
|
|
151
|
+
source) and installed in the profile (host.js byte-identical, guard at host.js:727/753). The
|
|
152
|
+
profile's minimumReleaseAge gate needed its dsh-memory exclusion extended to 0.1.21 in
|
|
153
|
+
pnpm-workspace.yaml, the same convention as every earlier version. The host half loads at
|
|
154
|
+
boot, so the guard only takes effect after a restart; installed 0.1.20 already appends WITH
|
|
155
|
+
the marker, so nothing is at risk while this waits.
|
|
148
156
|
- [x] **Restart, then check the tasks layer — DONE 2026-10-09.** The restart confirmed it. See the
|
|
149
157
|
tasks-layer section above for the two log entries and the counts. It writes to ANOTHER plugin's storage through
|
|
150
158
|
that plugin's service, so it deserves a real observation rather than a passing suite. After
|
package/package.json
CHANGED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/* eslint-disable no-console */
|
|
2
|
+
// One-shot diagnostic: decompress a session.v4.jsonl.zstd artifact with the
|
|
3
|
+
// harness's own frame primitives and report memory/map rows and their envelope.
|
|
4
|
+
import { readFileSync } from 'node:fs'
|
|
5
|
+
import { scanZstdFrames, decompressZstdFrame } from 'file:///D:/deepseek-harness/packages/session/session-persistence-jsonl/src/zstd.ts'
|
|
6
|
+
|
|
7
|
+
const file = process.argv[2]
|
|
8
|
+
if (!file) throw new Error('usage: inspect-log.ts <session.v4.jsonl.zstd>')
|
|
9
|
+
|
|
10
|
+
const buf = readFileSync(file)
|
|
11
|
+
const scan = scanZstdFrames(buf)
|
|
12
|
+
const lines: string[] = []
|
|
13
|
+
for (const frame of scan.frames) {
|
|
14
|
+
const plain = await decompressZstdFrame(buf.subarray(frame.start, frame.end))
|
|
15
|
+
lines.push(...plain.toString('utf8').split('\n').filter((l) => l.trim() !== ''))
|
|
16
|
+
}
|
|
17
|
+
console.log(`frames=${scan.frames.length} torn=${scan.tornStart ?? '-'} rows=${lines.length}`)
|
|
18
|
+
if (scan.tornStart !== undefined) console.log(`tornStart=${scan.tornStart}`)
|
|
19
|
+
|
|
20
|
+
const header = lines[0]
|
|
21
|
+
console.log(`header: ${header?.slice(0, 300) ?? '(none)'}`)
|
|
22
|
+
|
|
23
|
+
const hits = lines.filter((l) => l.startsWith('{"type":"memory/map"'))
|
|
24
|
+
console.log(`\nmemory/map EVENT rows: ${hits.length}`)
|
|
25
|
+
for (const h of hits) {
|
|
26
|
+
let env: { seq?: number; time?: number; ignorable?: unknown } = {}
|
|
27
|
+
try { env = JSON.parse(h) } catch { /* not json */ }
|
|
28
|
+
console.log(`seq=${env.seq} time=${env.time} ignorable=${JSON.stringify(env.ignorable)} keys=${Object.keys(env).join(',')}`)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const unmarked = hits.filter((l) => !l.includes('"ignorable":true'))
|
|
32
|
+
console.log(`\nUNMARKED memory/map events: ${unmarked.length}`)
|
|
33
|
+
for (const h of unmarked.slice(0, 10)) console.log(h.slice(0, 300))
|
|
34
|
+
|
|
35
|
+
console.log('\nlast 4 rows:')
|
|
36
|
+
for (const l of lines.slice(-4)) {
|
|
37
|
+
let env: { type?: string; seq?: number; time?: number } = {}
|
|
38
|
+
try { env = JSON.parse(l) } catch { /* not json */ }
|
|
39
|
+
console.log(`type=${env.type} seq=${env.seq} time=${env.time}`)
|
|
40
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/* eslint-disable no-console */
|
|
2
|
+
// One-shot repair for the dsh-memory "unknown event type" incident.
|
|
3
|
+
//
|
|
4
|
+
// Plugin <=0.1.19 appended `{"type":"memory/map",...}` events WITHOUT the
|
|
5
|
+
// `ignorable: true` envelope marker. The harness's fail-closed storage contract
|
|
6
|
+
// (session-persistence/src/storage-contract.ts) refuses such logs on every
|
|
7
|
+
// load: "unknown to this harness and not marked ignorable". This rewrites each
|
|
8
|
+
// poisoned log with the marker inserted into the memory/map rows, preserving
|
|
9
|
+
// every other byte, the header-only first frame, frame checksums and seq
|
|
10
|
+
// contiguity, then verifies the result through the harness's own validator.
|
|
11
|
+
//
|
|
12
|
+
// Idempotent: files with zero unmarked memory/map events are left untouched.
|
|
13
|
+
// Usage: node --import tsx/esm scripts/repair-ignorable.ts [session-dir ...]
|
|
14
|
+
// (no args = scan the default sessions root and repair every broken log)
|
|
15
|
+
import { copyFileSync, existsSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs'
|
|
16
|
+
import { join, basename } from 'node:path'
|
|
17
|
+
import { scanZstdFrames, decompressZstdFrame, decompressZstdPrefix, compressZstdFrame } from 'file:///D:/deepseek-harness/packages/session/session-persistence-jsonl/src/zstd.ts'
|
|
18
|
+
import { validateStoredEvents } from 'file:///D:/deepseek-harness/packages/session/session-persistence/src/storage-contract.ts'
|
|
19
|
+
|
|
20
|
+
const ROOT = 'C:/Users/erikb/.dsh/sessions'
|
|
21
|
+
const CUT_MS = Date.parse('2026-10-09T20:53:00Z')
|
|
22
|
+
|
|
23
|
+
/** Insert the marker into one memory/map row, preserving every other byte of the line. */
|
|
24
|
+
function markLine(line) {
|
|
25
|
+
if (!line.startsWith('{"type":"memory/map"') || line.includes('"ignorable":true')) return { line, marked: false }
|
|
26
|
+
const edited = line.replace(/^(\{"type":"memory\/map","seq":\d+,"time":\d+,)/, '$1"ignorable":true,')
|
|
27
|
+
if (edited === line) throw new Error(`memory/map row did not match the envelope pattern: ${line.slice(0, 80)}`)
|
|
28
|
+
return { line: edited, marked: true }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function markText(text) {
|
|
32
|
+
let marked = 0
|
|
33
|
+
const out = text.split('\n').map((line) => {
|
|
34
|
+
const r = markLine(line)
|
|
35
|
+
if (r.marked) marked += 1
|
|
36
|
+
return r.line
|
|
37
|
+
}).join('\n')
|
|
38
|
+
return { text: out, marked }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Repair one file in place; returns a report or throws. */
|
|
42
|
+
async function repairFile(file) {
|
|
43
|
+
const buf = readFileSync(file)
|
|
44
|
+
const scan = scanZstdFrames(buf)
|
|
45
|
+
const frames = []
|
|
46
|
+
for (const frame of scan.frames) {
|
|
47
|
+
const plain = await decompressZstdFrame(buf.subarray(frame.start, frame.end))
|
|
48
|
+
frames.push(plain.toString('utf8'))
|
|
49
|
+
}
|
|
50
|
+
if (scan.tornStart !== undefined) {
|
|
51
|
+
const prefix = await decompressZstdPrefix(buf.subarray(scan.tornStart))
|
|
52
|
+
frames.push(prefix.toString('utf8'))
|
|
53
|
+
}
|
|
54
|
+
let marked = 0
|
|
55
|
+
const edited = []
|
|
56
|
+
for (const text of frames) {
|
|
57
|
+
const r = markText(text)
|
|
58
|
+
marked += r.marked
|
|
59
|
+
edited.push(r.text)
|
|
60
|
+
}
|
|
61
|
+
if (marked === 0) return { file, repaired: false, reason: 'no unmarked memory/map events' }
|
|
62
|
+
|
|
63
|
+
// Compress each frame back as its own checksummed frame: frame 1 stays the
|
|
64
|
+
// header-only frame, batch boundaries are preserved, and a recovered torn
|
|
65
|
+
// prefix becomes a complete final frame.
|
|
66
|
+
const parts = []
|
|
67
|
+
for (const text of edited) parts.push(await compressZstdFrame(Buffer.from(text, 'utf8')))
|
|
68
|
+
const out = Buffer.concat(parts)
|
|
69
|
+
|
|
70
|
+
const backup = `${file}.bak-pre-ignorable`
|
|
71
|
+
if (!existsSync(backup)) copyFileSync(file, backup)
|
|
72
|
+
const tmp = `${file}.repair.tmp`
|
|
73
|
+
writeFileSync(tmp, out)
|
|
74
|
+
renameSync(tmp, file)
|
|
75
|
+
return { file, repaired: true, marked }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Verify a file the way the gateway will read it: frames decode, frame 1 is
|
|
79
|
+
* header-only, every row parses, seq is contiguous, and the storage contract
|
|
80
|
+
* (the exact validator that refused these logs) accepts every event. */
|
|
81
|
+
async function verifyFile(file) {
|
|
82
|
+
const buf = readFileSync(file)
|
|
83
|
+
const scan = scanZstdFrames(buf)
|
|
84
|
+
if (scan.frames.length === 0) throw new Error('no frames')
|
|
85
|
+
const header = (await decompressZstdFrame(buf.subarray(scan.frames[0].start, scan.frames[0].end))).toString('utf8')
|
|
86
|
+
if (header.indexOf('\n') !== header.length - 1) throw new Error('first frame is not exactly one header line')
|
|
87
|
+
const lines = [header.trimEnd()]
|
|
88
|
+
for (const frame of scan.frames.slice(1)) {
|
|
89
|
+
lines.push(...(await decompressZstdFrame(buf.subarray(frame.start, frame.end))).toString('utf8').split('\n').filter((l) => l.trim() !== ''))
|
|
90
|
+
}
|
|
91
|
+
if (scan.tornStart !== undefined) {
|
|
92
|
+
lines.push(...(await decompressZstdPrefix(buf.subarray(scan.tornStart))).toString('utf8').split('\n').filter((l) => l.trim() !== ''))
|
|
93
|
+
}
|
|
94
|
+
const rows = lines.map((l) => JSON.parse(l))
|
|
95
|
+
const meta = rows[0]
|
|
96
|
+
const events = rows.slice(1)
|
|
97
|
+
// Relative contiguity: a fork inherits a prefix, so the log may start at a nonzero seq.
|
|
98
|
+
for (let i = 1; i < events.length; i += 1) {
|
|
99
|
+
if (events[i].seq !== events[0].seq + i) throw new Error(`seq gap at index ${i}: ${events[i].seq}`)
|
|
100
|
+
}
|
|
101
|
+
const unmarked = events.filter((e) => e.type === 'memory/map' && e.ignorable !== true)
|
|
102
|
+
if (unmarked.length > 0) throw new Error(`${unmarked.length} memory/map events still unmarked`)
|
|
103
|
+
validateStoredEvents(meta, events, { kind: 'jsonl', path: file })
|
|
104
|
+
return { rows: rows.length, events: events.length, memoryMaps: events.filter((e) => e.type === 'memory/map').length }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function collectFiles() {
|
|
108
|
+
const dirs = process.argv.slice(2).filter((a) => !a.startsWith('-'))
|
|
109
|
+
if (dirs.length > 0) return dirs.map((d) => join(d, 'session.v4.jsonl.zstd'))
|
|
110
|
+
const out = []
|
|
111
|
+
const walk = (dir) => {
|
|
112
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
113
|
+
const p = join(dir, e.name)
|
|
114
|
+
if (e.isDirectory()) walk(p)
|
|
115
|
+
else if (e.name.endsWith('.zstd') && !e.name.includes('.bak') && statSync(p).mtimeMs >= CUT_MS) out.push(p)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
walk(ROOT)
|
|
119
|
+
return out
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
let repaired = 0
|
|
123
|
+
for (const file of collectFiles()) {
|
|
124
|
+
const r = await repairFile(file)
|
|
125
|
+
if (!r.repaired) continue
|
|
126
|
+
repaired += 1
|
|
127
|
+
const v = await verifyFile(file)
|
|
128
|
+
console.log(`repaired ${basename(file)}: marked ${r.marked} events; verified ${v.events} events, ${v.memoryMaps} memory/map rows, storage contract OK`)
|
|
129
|
+
}
|
|
130
|
+
console.log(`\nrepaired ${repaired} file(s); backups kept as <file>.bak-pre-ignorable`)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/* eslint-disable no-console */
|
|
2
|
+
// One-shot diagnostic: scan every session log modified since the first
|
|
3
|
+
// memory/map publish and report unmarked memory/map events per file.
|
|
4
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs'
|
|
5
|
+
import { join, basename } from 'node:path'
|
|
6
|
+
import { scanZstdFrames, decompressZstdFrame, decompressZstdPrefix } from 'file:///D:/deepseek-harness/packages/session/session-persistence-jsonl/src/zstd.ts'
|
|
7
|
+
|
|
8
|
+
const root = process.argv[2] ?? 'C:/Users/erikb/.dsh/sessions'
|
|
9
|
+
const cut = Date.parse(process.argv[3] ?? '2026-10-09T20:53:00Z')
|
|
10
|
+
|
|
11
|
+
function walk(dir: string): string[] {
|
|
12
|
+
const out: string[] = []
|
|
13
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
14
|
+
const p = join(dir, e.name)
|
|
15
|
+
if (e.isDirectory()) out.push(...walk(p))
|
|
16
|
+
else if (e.name.endsWith('.zstd') && !e.name.includes('.bak') && statSync(p).mtimeMs >= cut) out.push(p)
|
|
17
|
+
}
|
|
18
|
+
return out
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let scanned = 0
|
|
22
|
+
for (const file of walk(root)) {
|
|
23
|
+
scanned += 1
|
|
24
|
+
let text = ''
|
|
25
|
+
try {
|
|
26
|
+
const buf = readFileSync(file)
|
|
27
|
+
const scan = scanZstdFrames(buf)
|
|
28
|
+
for (const f of scan.frames) text += (await decompressZstdFrame(buf.subarray(f.start, f.end))).toString('utf8')
|
|
29
|
+
if (scan.tornStart !== undefined) text += (await decompressZstdPrefix(buf.subarray(scan.tornStart))).toString('utf8')
|
|
30
|
+
} catch (err) {
|
|
31
|
+
console.log(`${basename(file)}: DECOMPRESS ERROR ${String(err).slice(0, 120)}`)
|
|
32
|
+
continue
|
|
33
|
+
}
|
|
34
|
+
let events = 0
|
|
35
|
+
let unmarked = 0
|
|
36
|
+
for (const line of text.split('\n')) {
|
|
37
|
+
if (!line.startsWith('{"type":"memory/map"')) continue
|
|
38
|
+
events += 1
|
|
39
|
+
if (!line.includes('"ignorable":true')) unmarked += 1
|
|
40
|
+
}
|
|
41
|
+
if (events > 0) console.log(`${unmarked > 0 ? 'BROKEN ' : 'ok '} events=${events} unmarked=${unmarked} ${file}`)
|
|
42
|
+
}
|
|
43
|
+
console.log(`scanned ${scanned} files`)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/* eslint-disable no-console */
|
|
2
|
+
// Read-only: run the harness's own storage-contract validator over a live log.
|
|
3
|
+
import { readFileSync } from 'node:fs'
|
|
4
|
+
import { scanZstdFrames, decompressZstdFrame, decompressZstdPrefix } from 'file:///D:/deepseek-harness/packages/session/session-persistence-jsonl/src/zstd.ts'
|
|
5
|
+
import { validateStoredEvents } from 'file:///D:/deepseek-harness/packages/session/session-persistence/src/storage-contract.ts'
|
|
6
|
+
|
|
7
|
+
for (const file of process.argv.slice(2)) {
|
|
8
|
+
try {
|
|
9
|
+
const buf = readFileSync(file)
|
|
10
|
+
const scan = scanZstdFrames(buf)
|
|
11
|
+
const header = (await decompressZstdFrame(buf.subarray(scan.frames[0].start, scan.frames[0].end))).toString('utf8')
|
|
12
|
+
if (header.indexOf('\n') !== header.length - 1) throw new Error('first frame is not exactly one header line')
|
|
13
|
+
const lines = [header.trimEnd()]
|
|
14
|
+
for (const frame of scan.frames.slice(1)) {
|
|
15
|
+
lines.push(...(await decompressZstdFrame(buf.subarray(frame.start, frame.end))).toString('utf8').split('\n').filter((l) => l.trim() !== ''))
|
|
16
|
+
}
|
|
17
|
+
if (scan.tornStart !== undefined) {
|
|
18
|
+
lines.push(...(await decompressZstdPrefix(buf.subarray(scan.tornStart))).toString('utf8').split('\n').filter((l) => l.trim() !== ''))
|
|
19
|
+
}
|
|
20
|
+
const rows = lines.map((l) => JSON.parse(l))
|
|
21
|
+
validateStoredEvents(rows[0], rows.slice(1), { kind: 'jsonl', path: file })
|
|
22
|
+
console.log(`OK ${rows.length} rows, ${rows.slice(1).filter((e) => e.type === 'memory/map').length} memory/map: ${file}`)
|
|
23
|
+
} catch (err) {
|
|
24
|
+
console.log(`FAIL ${file}: ${String(err).slice(0, 300)}`)
|
|
25
|
+
}
|
|
26
|
+
}
|
package/src/host.js
CHANGED
|
@@ -29,15 +29,28 @@ const TASKS_BACKUP_MS = 30 * 60 * 1000
|
|
|
29
29
|
const IDLE_TICK_MS = 60_000
|
|
30
30
|
|
|
31
31
|
function makeGitRunner(ctx) {
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
// The subprocess service is NOT resolvable during apply. cordis ctx.get() only returns an
|
|
33
|
+
// implementation whose PROVIDING FIBER IS ACTIVE (vendor/cordis reflect.ts _getImpl), and no
|
|
34
|
+
// fiber is active while the composition is still mounting. The first version probed here,
|
|
35
|
+
// once, at wire time - it cached undefined, returned null, and every git path was dead
|
|
36
|
+
// forever: zero sync events ever recorded, and the history hook threw "Cannot read
|
|
37
|
+
// properties of null (reading 'run')" every hour. The service exists by first USE (an agent
|
|
38
|
+
// pre-step, minutes after boot), so resolve lazily and keep re-probing until it appears.
|
|
39
|
+
let subprocess
|
|
40
|
+
const probe = () => {
|
|
41
|
+
const svc = ctx.get('subprocess')
|
|
42
|
+
if (svc && typeof svc.resolveExecutable === 'function' && typeof svc.spawn === 'function') subprocess = svc
|
|
43
|
+
return subprocess
|
|
44
|
+
}
|
|
34
45
|
let executablePromise
|
|
35
46
|
const run = async (argv, opts = {}) => {
|
|
36
|
-
|
|
47
|
+
const svc = probe()
|
|
48
|
+
if (!svc) return { exitCode: 127, stdout: '', stderr: 'subprocess service unavailable' }
|
|
49
|
+
if (!executablePromise) executablePromise = svc.resolveExecutable('git', undefined, undefined).catch(() => null)
|
|
37
50
|
const executable = await executablePromise
|
|
38
51
|
if (!executable) return { exitCode: 127, stdout: '', stderr: 'git unavailable' }
|
|
39
52
|
try {
|
|
40
|
-
const handle =
|
|
53
|
+
const handle = svc.spawn({
|
|
41
54
|
argv: [executable, ...argv],
|
|
42
55
|
cwd: opts.cwd,
|
|
43
56
|
stdio: { stdin: 'ignore', stdout: { maxBytes: 4 * 1024 * 1024 }, stderr: { maxBytes: 16 * 1024 } },
|
|
@@ -52,7 +65,9 @@ function makeGitRunner(ctx) {
|
|
|
52
65
|
return { exitCode: 1, stdout: '', stderr: String(error) }
|
|
53
66
|
}
|
|
54
67
|
}
|
|
55
|
-
|
|
68
|
+
const runner = new GitRunner(run)
|
|
69
|
+
runner.available = () => Boolean(probe())
|
|
70
|
+
return runner
|
|
56
71
|
}
|
|
57
72
|
|
|
58
73
|
function resolveTarget(agent) {
|
|
@@ -74,6 +89,7 @@ export function wireHost(ctx, store, cfg) {
|
|
|
74
89
|
let restoreAttempted = false
|
|
75
90
|
const lastMapPublish = new Map()
|
|
76
91
|
const lastMapSignature = new Map()
|
|
92
|
+
const mapPublishSuppressed = new Set()
|
|
77
93
|
let lastHistoryAttempt = 0
|
|
78
94
|
let syncInFlight = false
|
|
79
95
|
|
|
@@ -234,6 +250,9 @@ export function wireHost(ctx, store, cfg) {
|
|
|
234
250
|
if (!workspace) return { ok: false, reason: 'no workspace for this session', added: 0 }
|
|
235
251
|
const path = readAssociations(store.root)[workspace]?.path
|
|
236
252
|
if (!path) return { ok: false, reason: 'no local folder associated with ' + workspace, added: 0 }
|
|
253
|
+
// Report the honest reason instead of throwing: a hook that throws hourly because a service
|
|
254
|
+
// was probed too early must never look like a repository with no commits.
|
|
255
|
+
if (!git.available()) return { ok: false, reason: 'subprocess service unavailable', added: 0 }
|
|
237
256
|
return ingestHistory(store.root, workspace, path, (argv, opts) => git.run(argv, opts), { limit })
|
|
238
257
|
}
|
|
239
258
|
|
|
@@ -569,6 +588,9 @@ export function wireHost(ctx, store, cfg) {
|
|
|
569
588
|
}
|
|
570
589
|
|
|
571
590
|
async function runSync(force = false) {
|
|
591
|
+
// Not a git failure: no CONFLICT marker, no status record - the capability simply is not
|
|
592
|
+
// there yet. available() re-probes, so this is true only while the host is still mounting.
|
|
593
|
+
if (!git.available()) return { initialized: false, committed: false, synced: false, error: 'subprocess service unavailable' }
|
|
572
594
|
const branch = cfg.gitBranch || 'main'
|
|
573
595
|
const hasGit = existsSync(join(store.root, '.git'))
|
|
574
596
|
if (!hasGit) {
|
|
@@ -721,6 +743,9 @@ export function wireHost(ctx, store, cfg) {
|
|
|
721
743
|
try {
|
|
722
744
|
if (!session || typeof session.append !== 'function' || !session.id) return
|
|
723
745
|
if (isSubagentSession(session)) return
|
|
746
|
+
// A session whose harness did not honor ignorable is poisoned by every further append;
|
|
747
|
+
// the decision to stop is made once, below, and honored on every later attempt.
|
|
748
|
+
if (mapPublishSuppressed.has(session.id)) return
|
|
724
749
|
const now = Date.now()
|
|
725
750
|
if (!force && now - (lastMapPublish.get(session.id) ?? 0) < MAP_PUBLISH_MS) return
|
|
726
751
|
lastMapPublish.set(session.id, now)
|
|
@@ -737,7 +762,21 @@ export function wireHost(ctx, store, cfg) {
|
|
|
737
762
|
const signature = JSON.stringify({ ...payload, generatedAt: '' })
|
|
738
763
|
if (!force && lastMapSignature.get(session.id) === signature) return
|
|
739
764
|
lastMapSignature.set(session.id, signature)
|
|
740
|
-
|
|
765
|
+
// Out-of-repo event types are absent from KNOWN_SESSION_EVENT_TYPES; without
|
|
766
|
+
// ignorable:true the persistence read path refuses the whole session log.
|
|
767
|
+
// The append returns the logged event: if the harness did NOT stamp
|
|
768
|
+
// ignorable onto it (a harness older than the option), the very next load
|
|
769
|
+
// of this session would fail with "unknown to this harness and not marked
|
|
770
|
+
// ignorable". Detecting that from the return value lets us stop before a
|
|
771
|
+
// log is poisoned, instead of discovering it when history fails to load.
|
|
772
|
+
const logged = session.append('memory/map', payload, { ignorable: true })
|
|
773
|
+
if (logged?.ignorable !== true) {
|
|
774
|
+
if (!mapPublishSuppressed.has(session.id)) {
|
|
775
|
+
mapPublishSuppressed.add(session.id)
|
|
776
|
+
logRun(store.root, 'map-publish', { ok: false, error: 'harness did not stamp ignorable; publishing suppressed for this session' })
|
|
777
|
+
}
|
|
778
|
+
return
|
|
779
|
+
}
|
|
741
780
|
logRun(store.root, 'map-publish', { ok: true, scope, key: key ?? '', bytes: signature.length })
|
|
742
781
|
} catch (error) {
|
|
743
782
|
// Never let a view break the conversation it is describing.
|
|
@@ -746,7 +785,7 @@ export function wireHost(ctx, store, cfg) {
|
|
|
746
785
|
}
|
|
747
786
|
|
|
748
787
|
function maybeSync() {
|
|
749
|
-
if (syncInFlight || !git) return
|
|
788
|
+
if (syncInFlight || !git.available()) return
|
|
750
789
|
if (!scheduler.shouldCommit() && !scheduler.shouldSync()) return
|
|
751
790
|
syncInFlight = true
|
|
752
791
|
// Sync wrote its outcome only to state/sync.json, so the log could not answer whether the
|