@sema-agent/core 7.38.1 → 7.38.2

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/CHANGELOG.md CHANGED
@@ -1,3 +1,21 @@
1
+ ## 7.38.2 — 2026-10-02
2
+
3
+ **Hard-breaking changes:** none.
4
+
5
+ **Closed sets:** none. **Package exports:** +0 / −0.
6
+
7
+ ### A JSONL tail that parses to an object is a record, newline or not (#1278; behavior (widen: the file backends' JSONL read; narrow: what the open-time repair discards; a failed append leaves no bytes); +0 exports) (@server @test)
8
+
9
+ - **Before (v7.38.1).** `readJsonlRecords` served nothing after a file's last newline, and `AppendLog` cut that tail away when it opened and again at the append door whenever the file had moved off a record boundary. A record that was whole on disk and lacked only its final `\n` was invisible to every replay and then physically removed. Every file store on the format shared it: session, mailbox, memory, workflow journal, and the shared-ledger stores (checkpoint, background agent, workflow run).
10
+ - **Now.** One function, `jsonlTextFormOf` (`src/stores/file/fs-atomic.ts`), judges a segment, and the three stations that must answer "is the unterminated tail a record" all ask it: the read, the open-time repair, the append door's boundary repair. A file on this format holds one JSON object per line and no strict prefix of a JSON object parses, so a tail that parses to an object is a whole record that lost only its newline: the read serves it; the open-time repair and the append door end its line (the bytes stay); nothing is disclosed (it is not damage). A tail that does not parse is a crash's partial write: not served, cut back to the last newline, silent (unchanged). A tail that parses to a non-object (number, string, boolean, `null`, array) is bytes the store never wrote: skipped and cut, and now disclosed once under `not-an-object` exactly as the same bytes with a newline are (it was silent). The consuming store's own floor (`judge`) applies to the tail like to any line. `truncateTornTail` is renamed `repairJsonlTail` and cuts by byte offset. And an append whose write fails (the write throws, or makes no progress) leaves no bytes of its own: `AppendLog.append` cuts the file back to where the write began, provided the file is exactly that size plus the bytes it wrote (a peer that appended after it owns what follows; a size that cannot be read, or a cut that fails, leaves the file for the next append's tail repair), then rethrows the write's own error. A caller that retries the record writes it once; without this, a failure that left every byte but the newline would, under the rule above, be kept and then written again by the retry.
11
+ - **Who is hurt.** A consumer that relied on an unterminated last line never being a record: it now reads one. No store in this package does (the replays read what a record carries, not how its line ended). On the operator side, a host that listens on a store's corrupt-read seat (`onCorruptRead`) hears one more `not-an-object` disclosure when a file's unterminated last segment parses to a non-object (a number, string, boolean, `null` or array), which was silent before; the same bytes with a newline already disclosed. A caller that relied on a failed append's partial bytes staying on disk: none can, the next append's tail repair cut them.
12
+ - **Compensation.** None needed; there is no switch for the old drop, because a whole record on disk is a record.
13
+ - **Known limit (KL-1278-a).** A whole object followed by a half one with no newline between (`{"a":1}{"b"`), or two whole objects with no newline between, is one segment that does not parse: it is cut whole, before and after, because recovering its first object would be parsing a prefix.
14
+ - **Known limit (KL-1278-b).** The rollback of a failed append has two preconditions: nobody appended after it, and the cut itself works. A peer that appended after our partial bytes leaves its line glued behind them, one unparseable interior line (disclosed `torn-json`, both pieces lost), as before; a failed cut (EIO) that leaves a whole-but-unterminated record is kept by the next open or append door, so a retry writes it twice.
15
+ - **Known limit (KL-1278-c).** A tail whose bytes are invalid UTF-8 inside a closed object (`{"id":"\xE4"}`) is decoded with U+FFFD like an interior line always was, so it is served and its bytes kept; a crash leaves a prefix, never that shape.
16
+ - **Known limit (KL-1278-d).** A non-object tail met first by the open-time repair or the append door, with no read before it, is cut without disclosure (`AppendLog` has no disclosure seat); every file store reads before it opens.
17
+ - Pins: `test/backlog1278-jsonl-tail-newline.test.ts` — ① a whole tail without its newline is read, silently; ② open ends its line; ③ an append after that open is its own line (byte-exact, three records); ④ a half tail is still cut (incl. one cut inside a multi-byte character, and a whole multi-byte tail); ⑤ non-object tails (`123` `[1]` `"x"` `null` `true`) disclosed once and cut, read identically with and without the newline; ⑥ empty / lone-newline / clean / blank-interior files change no byte; ⑧ a 3 MiB tail judged whole or cut; ⑨ a peer's whole unterminated record met by the append door is kept, a peer's half record is cut; ⑩ `{"a":1}{"b"` and two glued objects are cut whole; ⑪ the store's floor applies to the tail; ⑫ the present form of KL-1278-c; 9c a peer's non-object tail is cut by the append door; ⑬ a failed append leaves no bytes and the retry writes the record once (13 partial write then an error, 13b every byte but the newline then an error, 13d a short write then zero progress), 13c a peer's whole record landed before the error is not cut, 13e a peer appended after our partial bytes is not cut (present form of KL-1278-b). `test/file-checkpoint-store.test.ts` PIN 7 — a real checkpoint ledger hand-stripped of its final newline replays, and the next write does not glue. `test/defectscan-truncation-invariant-surface.test.ts` — the exempt entry for the renamed function removed.
18
+
1
19
  ## 7.38.1 — 2026-10-02
2
20
 
3
21
  **Hard-breaking changes:** none.
@@ -6,9 +24,9 @@
6
24
 
7
25
  ### Home-anchored read-deny rows are placed under the account home as well as the child's `HOME` (#1244; behavior (tighten: the read deny table); +0 exports) (@server @cli @client-core @test)
8
26
 
9
- - **Before (v7.38.0).** The home-anchored rows of the read deny table (`~/.sema/settings.json`, `~/.ssh`, `~/.aws`, …) were placed under one home: the `HOME` the shell child receives (`ExecutionEnv.homeDir`; `NodeExecutionEnv` reads it off `shellEnv` over the inherited environment). A host that handed the child a temporary `HOME` moved the whole fence with it — the user's real home directory (the account database's, `os.userInfo().homedir`) held no row, and the structured read tools and the shell read station served `~/.sema/settings.json` there.
27
+ - **Before (v7.38.0).** The home-anchored rows of the read deny table (`~/.sema/settings.json`, `~/.claude/settings.json`, `~/.mcp.json`, …; the credential rows `.ssh` / `.aws` match by basename anywhere and were never home-bound) were placed under one home: the `HOME` the shell child receives (`ExecutionEnv.homeDir`; `NodeExecutionEnv` reads it off `shellEnv` over the inherited environment). A host that handed the child a temporary `HOME` moved the whole fence with it — the user's real home directory (the account database's, `os.userInfo().homedir`) held no row, and the structured read tools and the shell read station served `~/.sema/settings.json` there.
10
28
  - **Now.** `declaredReadDenyHomes` returns the declared home and, when its lexically normalized spelling differs, the account home. The home set is fixed ONCE per placement — the Runner's (per run), the library-direct toolkit's own, or a host's explicit `readDenyHomes` taken as the home set (the account database is then not asked and no home is added) — and every home of the set, looked up or listed, is anchored at its spelling and at its canonical spelling where the environment answered one — and every judgment reads that set: a judge carries the home views of the placement it was compiled at, so a path spelled through a firmlink or a bind mount of a home is respelled at the home without asking the account again. The Runner hands its tool band that placement itself — the very home list it returned carries its views — so the band never reads the homes a second time; a band handed any other list with the placement's links (a copy) holds its home-anchored rows at every location and says so on `console.warn` (an engine defect, never a silent narrower view). The DECLARED home keeps the v7.38.0 law: its probes wait the gate bound, a placement whose declared home did not answer holds the rows at every location and is asked again, and its directory chain is asked again by every judgment until it answers (a home created after the run began is read once it exists); its views stay under every answer of the account lookup, also under no answer, so a user's deny rule and the write face meet its aliases as in v7.38.0. The ACCOUNT home (and every entry of an explicit list that is not the declared home) is best effort and bounded: its canonical spelling, its identity chain and where its rows' heads lead are read once per placement under one 2000 ms bound; what answered is kept and only what did not is lost, that home's alone — a canonical spelling that answered anchors the rows there at once; a chain that did not answer leaves a path through an alias of the home judged as spelled, a head that did not answer adds no linked location, until the next placement; a canonical spelling that did not answer leaves the rows at the home's spelling alone — the placement stays whole, and one `console.warn` line says so. In the chain a directory that is a home (read off the home's own canonical directory, where its identity answered) keeps the home's own spelling over an ancestor's. A head under a spelling that is no directory — a file, a symlink to a file, a device node, a socket: the environment's path resolution answers `not_directory` — has nothing below it (an answer; before, only a plain file counted: a `HOME` that is a symlink to a file left the placement not whole, replayed on every call). The `~` expansion base is unchanged: `ExecutionEnv.homeDir` stays the directory the child's own `~` names. An environment that declares no absolute home still places nothing (a home-anchored row then holds its run anywhere); the account home is no stand-in for it.
11
- - **Who is hurt.** A read of a file under the account home that matches a home-anchored row (`~/.sema/settings.json`, `~/.ssh/…`, `~/.aws/…`) while the child's `HOME` is elsewhere: refused (`read_path_denied`) where it was served. A host that lists `readDenyHomes` itself sees the same placement, and its views now cover every listed home (v7.38.0 respelled aliases of the declared home only: an alias spelling of another listed home was served); its explicit list stays a legal channel and can be withdrawn once it carries nothing the default does not. A remote or sandboxed leg gets the engine host's account home as one more anchor: a row over a path that may not exist there. A deployment whose account lookup fails for a reason other than "no passwd row" gets the over-strict placement above (rows held at every location) until the lookup answers. A hung account home makes a placement wait up to 2 s more, once per placement (the Runner's tool band judges with the run's placement and reads no home again). A host that passes BOTH `readDenyHomes` and `readDenyHomeLinks` (a hand-built pair) is now taken for the engine's own handoff of a placement it cannot find: the home-anchored rows are held at every location and `console.warn` names it (v7.38.0 placed the rows at the listed homes); no sibling host makes that call — pass `readDenyHomes` alone and let the toolkit resolve where the heads lead. No sibling host lists one today: server's only home seat is the execution environment's `homeDir` (`boot/execution-env.ts`), so its deployments get the account home from this default with no change on its side.
29
+ - **Who is hurt.** A read of a file under the account home that matches a home-anchored row (`~/.sema/settings.json`, `~/.claude/settings.json`, `~/.mcp.json`) while the child's `HOME` is elsewhere: refused (`read_path_denied`) where it was served. A host that lists `readDenyHomes` itself sees the same placement, and its views now cover every listed home (v7.38.0 respelled aliases of the declared home only: an alias spelling of another listed home was served); its explicit list stays a legal channel and can be withdrawn once it carries nothing the default does not. A remote or sandboxed leg gets the engine host's account home as one more anchor: a row over a path that may not exist there. A deployment whose account lookup fails for a reason other than "no passwd row" gets the over-strict placement above (rows held at every location) until the lookup answers. A hung account home makes a placement wait up to 2 s more, once per placement (the Runner's tool band judges with the run's placement and reads no home again). A host that passes BOTH `readDenyHomes` and `readDenyHomeLinks` (a hand-built pair) is now taken for the engine's own handoff of a placement it cannot find: the home-anchored rows are held at every location and `console.warn` names it (v7.38.0 placed the rows at the listed homes); no sibling host makes that call — pass `readDenyHomes` alone and let the toolkit resolve where the heads lead. No sibling host lists one today: server's only home seat is the execution environment's `homeDir` (`boot/execution-env.ts`), so its deployments get the account home from this default with no change on its side.
12
30
  - **Compensation.** None needed for the common case (`HOME` equals the account home: one home, nothing changes). A deployment that really reads a file under its own account home that a row names lists the row in `readDenyBuiltinExclude`.
13
31
  - **Known limit (KL-1244-a).** The account lookup has three answers, the split the environment's `canonicalPath` already meets, and NOTHING is kept for the process: every placement asks afresh, and a placement's judgments never ask. A home places. **No account home** — `os.userInfo()` throws the libuv "no passwd row" error (`info.code === "ENOENT"`, a container that runs an arbitrary uid; also a directory service that is down and says so) or answers a non-absolute path — places the rows under the declared home alone, a whole placement; the missing row is not a fault and does not warn (a non-absolute answer warns once). A library-direct toolkit does not place again after a whole placement, so an account home that answers later is not placed for that toolkit's lifetime; the Runner's next run places it. **No answer** on the first placement — any other failure, whatever the error object looks like — says so once on `console.warn` and yields an EMPTY home list (`readDenyHomesOf`, `declaredReadDenyHomes`), the engine's rule for "no home known": the home-anchored rows hold their run at every location until the lookup answers (the project's own `.sema/settings.json` is refused too — over-strict, recoverable) and the placement is asked again at the next placement; the home views keep the declared home meanwhile. `readDenyHomesOf` returns `[]` both when the environment declares no home and when the lookup has no answer, so a host cannot tell them apart and should not cache the list: take it at every mount, or omit `readDenyHomes` and let the toolkit place itself. A toolkit whose table has no home-anchored row places nothing and its views read the declared home (or the list it is handed) without asking the account. A structured notice would be a closed-set row; a minor release carries it.
14
32
  - **Known limit (KL-1244-b).** An auxiliary home loses, until the next placement, exactly the parts that did not answer within its 2000 ms bound: a chain that did not answer — paths through an alias of it are judged as spelled; a head that did not answer — no linked location for it; a canonical spelling that did not answer — the rows sit at its spelling alone (neither its canonical spelling nor its linked targets are guarded). The next placement is the Runner's next run; a library-direct toolkit does not place again for that. A directory the probes may not enter (EACCES) is no answer: as the declared home it leaves the placement not whole (replayed per call; the rows stay at its spellings). **Known limit (KL-1244-d).** The public `judgeShellRead` has no placement to read — the boundary's deny seats are the host's own compile and the input carries no home list — so its views read the declared home and the account home looked up for that call; rows the host placed at another home get no alias views there (as in v7.38.0, whose public judgment read the declared home only); an optional input seat comes with a minor. **Known limit (KL-1244-c).** A user's own `~/` persisted permission rule (`Read(~/.aws/**)` …) still resolves against the environment's declared home only (`prepare-task.ts`, `prepare-policy-chain.ts`, `permission-rule-lanes.ts`); under a temporary `HOME` such a rule does not cover the real home directory. The built-in read deny table does.
@@ -13,6 +13,11 @@ import { type ContractAssertionRunner } from "./contract-harness.js";
13
13
  * turns a recoverable incident into a silent one. Which read-back failures a store TOLERATES (an
14
14
  * enumeration that skips one unreadable row and serves the rest, a torn trailing write read as absent)
15
15
  * is a per-store fact registered beside that store's own contract, never assumed from another store's.
16
+ * On the JSONL file format the unterminated last segment is judged by the same parse as every other line
17
+ * (`readJsonlRecords`): one that parses to an object is a whole record that lost only its newline (served,
18
+ * and its line ended at the next open or append), one that does not parse is a crash's partial write (absent,
19
+ * cut back to the last newline), so a newline is the boundary's spelling and never the condition of a record
20
+ * existing.
16
21
  */
17
22
  export declare const CORRUPT_BODY_FORMS: readonly ["not-an-object", "missing-required", "wrong-type", "torn-json"];
18
23
  /** One way a persisted body can fail to be the shape its store writes. */
@@ -36,8 +36,9 @@ export declare function ensureDir(dir: string): void;
36
36
  export declare function atomicWriteFile(tmpDir: string, target: string, bytes: string): void;
37
37
  /**
38
38
  * Read a JSONL file and return every COMPLETE, parseable record — the universal torn-tail recovery (§2.2):
39
- * - a trailing line with no terminating `\n` (a crash mid-write) is discarded,
40
- * - any line (interior or tail) that fails `JSON.parse` is skipped (never throws — `listRemoteAgentMetadata`
39
+ * - the last segment, when no `\n` ends it, is judged as every line is (`@contract store.read_back_shape_judged`):
40
+ * an object is served, an unparseable remnant is a crash's partial write and is discarded,
41
+ * - any interior line that fails `JSON.parse` is skipped (never throws — `listRemoteAgentMetadata`
41
42
  * parity), so a single corrupt interior line never poisons the whole replay.
42
43
  * A missing file → `[]`. This is why JSONL beats one big JSON blob: a torn tail can never corrupt the store.
43
44
  *
@@ -48,10 +49,11 @@ export declare function atomicWriteFile(tmpDir: string, target: string, bytes: s
48
49
  * carry my members at all?"); a record it rejects is skipped as `missing-required` on the same road, so
49
50
  * the count below covers every skip.
50
51
  *
51
- * `onCorrupt` (optional): one `record` disclosure per skipped INTERIOR record, then — iff any was skipped —
52
- * one `replay` tally. The torn TAIL stays out of both by design: dying mid-write is the crash shape this
53
- * format exists to absorb. The callback is invoked as given — a seat that must not throw swallow-guards on
54
- * ITS side.
52
+ * `onCorrupt` (optional): one `record` disclosure per skipped record, then — iff any was skipped — one
53
+ * `replay` tally. The unparseable unterminated tail stays out of both by design: dying mid-write is the
54
+ * crash shape this format exists to absorb. An unterminated tail that parses to a non-object is not that
55
+ * shape (no prefix of an object parses to one), so it is disclosed like any other foreign line. The callback
56
+ * is invoked as given — a seat that must not throw swallow-guards on ITS side.
55
57
  */
56
58
  export declare function readJsonlRecords<T>(path: string, onCorrupt?: (info: CorruptRecordRead | CorruptReplayTally) => void, judge?: JsonlRecordJudge): T[];
57
59
  /** A consuming store's floor on a parsed JSONL object: `accepts` = one of its records; `what` names a
@@ -114,7 +116,7 @@ export declare class AppendLog {
114
116
  *
115
117
  * It is the cheap arbiter for "did the file change under us since we last left it whole": equal ⇒
116
118
  * nothing to check, different ⇒ read the last byte and repair if it is not a newline. See
117
- * {@link repairTornTail}.
119
+ * {@link repairTail}.
118
120
  */
119
121
  private boundary;
120
122
  constructor(path: string);
@@ -122,7 +124,7 @@ export declare class AppendLog {
122
124
  /**
123
125
  * RB-438 (2026-07-31, hardening probe R2) — re-establish the last-complete-record boundary before a write.
124
126
  *
125
- * The constructor's {@link truncateTornTail} (and the RB-167 lazy reopen) only ever ran at OPEN time, and
127
+ * The constructor's {@link repairJsonlTail} (and the RB-167 lazy reopen) only ever ran at OPEN time, and
126
128
  * the RB-66 loop below only repairs a SHORT write — not a `writeSync` that THROWS mid-loop (ENOSPC/EDQUOT/
127
129
  * EIO after a partial `write(2)`, the classic full-volume shape) and not the `n <= 0` guard. Both leave
128
130
  * `written` bytes on disk with no terminating newline while the fd and this object stay live, and every
@@ -133,9 +135,10 @@ export declare class AppendLog {
133
135
  *
134
136
  * Cost on the healthy path is ONE `fstat` per append (the sizes match and we return); the whole-file read
135
137
  * only happens when the file actually moved off a boundary. A legitimate concurrent appender (another
136
- * process under the boot-lock handoff) leaves the file newline-terminated, so it is adopted, not truncated.
138
+ * process under the boot-lock handoff) leaves the file newline-terminated, so it is adopted, not truncated; a
139
+ * whole record it left without its newline is kept and ended, and only a partial one is cut.
137
140
  */
138
- private repairTornTail;
141
+ private repairTail;
139
142
  /**
140
143
  * Append one record (`json + "\n"`). `fsync:true` makes the record durable before return.
141
144
  *
@@ -145,8 +148,18 @@ export declare class AppendLog {
145
148
  * line with no trailing newline, so the NEXT append concatenates onto it and replay drops BOTH records.
146
149
  * Loop until every byte lands (write(2) returns how many it took) — the O_APPEND atomicity per call is
147
150
  * unchanged, and a genuine I/O error still throws.
151
+ *
152
+ * A write that fails leaves no bytes of its own: the file is cut back to where the write began, so a caller
153
+ * that retries the record writes it once — a record that failed is never also half-present.
148
154
  */
149
155
  append(record: unknown, fsync: boolean): void;
156
+ /**
157
+ * Cut the file back to `startedAt` after a write that threw, provided the bytes past it are exactly this
158
+ * write's own (`written` of them): a peer that appended after us owns what follows, and its bytes are not
159
+ * ours to cut. Best effort — the error the caller must see is the write's, so a rollback that cannot run
160
+ * answers false and the next append's tail repair judges what is left.
161
+ */
162
+ private rollBackFailedWrite;
150
163
  close(): void;
151
164
  /**
152
165
  * RB-167 — release the descriptor for a compaction SWAP. The next write reopens it lazily.
@@ -1,4 +1,4 @@
1
- import { closeSync, constants as FS, existsSync, fstatSync, linkSync, mkdirSync, openSync, readFileSync, readSync, renameSync, truncateSync, unlinkSync, writeFileSync, writeSync, fsyncSync, } from "node:fs";
1
+ import { appendFileSync, closeSync, constants as FS, existsSync, fstatSync, ftruncateSync, linkSync, mkdirSync, openSync, readFileSync, readSync, renameSync, truncateSync, unlinkSync, writeFileSync, writeSync, fsyncSync, } from "node:fs";
2
2
  import { homedir, hostname, uptime } from "node:os";
3
3
  import { hrtime } from "node:process";
4
4
  import { basename, dirname, join, resolve as resolvePath } from "node:path";
@@ -106,6 +106,18 @@ function basenameOf(p) {
106
106
  function dirnameOf(p) {
107
107
  return dirname(p);
108
108
  }
109
+ function jsonlTextFormOf(text) {
110
+ let value;
111
+ try {
112
+ value = JSON.parse(text);
113
+ }
114
+ catch {
115
+ return { form: "unparseable" };
116
+ }
117
+ if (value === null || typeof value !== "object" || Array.isArray(value))
118
+ return { form: "not-an-object", value };
119
+ return { form: "record", record: value };
120
+ }
109
121
  export function readJsonlRecords(path, onCorrupt, judge) {
110
122
  let raw;
111
123
  try {
@@ -119,7 +131,6 @@ export function readJsonlRecords(path, onCorrupt, judge) {
119
131
  if (raw === "")
120
132
  return [];
121
133
  const parts = raw.split("\n");
122
- const endedClean = parts[parts.length - 1] === "";
123
134
  const out = [];
124
135
  let dropped = 0;
125
136
  const discloseSkip = (form, line, what) => {
@@ -127,28 +138,25 @@ export function readJsonlRecords(path, onCorrupt, judge) {
127
138
  onCorrupt?.({ kind: "record", path, reason: `${form}: ${what} at line ${line} (skipped)`, line, form });
128
139
  };
129
140
  for (let i = 0; i < parts.length; i++) {
130
- if (i === parts.length - 1 && !endedClean)
131
- break;
132
141
  const line = parts[i];
133
142
  if (line === "")
134
143
  continue;
135
- let record;
136
- try {
137
- record = JSON.parse(line);
138
- }
139
- catch {
144
+ const read = jsonlTextFormOf(line);
145
+ if (read.form === "unparseable") {
146
+ if (i === parts.length - 1)
147
+ break;
140
148
  discloseSkip("torn-json", i + 1, "unparseable jsonl record");
141
149
  continue;
142
150
  }
143
- if (record === null || typeof record !== "object" || Array.isArray(record)) {
144
- discloseSkip("not-an-object", i + 1, `jsonl record is ${jsonTypeName(record)}, not an object`);
151
+ if (read.form === "not-an-object") {
152
+ discloseSkip("not-an-object", i + 1, `jsonl record is ${jsonTypeName(read.value)}, not an object`);
145
153
  continue;
146
154
  }
147
- if (judge !== undefined && !judge.accepts(record)) {
155
+ if (judge !== undefined && !judge.accepts(read.record)) {
148
156
  discloseSkip("missing-required", i + 1, judge.what);
149
157
  continue;
150
158
  }
151
- out.push(record);
159
+ out.push(read.record);
152
160
  }
153
161
  if (dropped > 0) {
154
162
  const total = out.length + dropped;
@@ -175,7 +183,7 @@ export class AppendLog {
175
183
  constructor(path) {
176
184
  this.path = path;
177
185
  ensureDir(dirnameOf(path));
178
- truncateTornTail(path);
186
+ repairJsonlTail(path);
179
187
  this.fd = openSync(path, "a+", 0o600);
180
188
  this.boundary = this.sizeOrUnknown();
181
189
  }
@@ -187,7 +195,7 @@ export class AppendLog {
187
195
  return -1;
188
196
  }
189
197
  }
190
- repairTornTail() {
198
+ repairTail() {
191
199
  const size = this.sizeOrUnknown();
192
200
  if (size < 0) {
193
201
  this.boundary = -1;
@@ -207,14 +215,15 @@ export class AppendLog {
207
215
  }
208
216
  }
209
217
  catch {
218
+ this.boundary = -1;
210
219
  return;
211
220
  }
212
- truncateTornTail(this.path);
221
+ repairJsonlTail(this.path);
213
222
  this.boundary = this.sizeOrUnknown();
214
223
  }
215
224
  append(record, fsync) {
216
225
  if (this.fd < 0 && !this.disposed) {
217
- truncateTornTail(this.path);
226
+ repairJsonlTail(this.path);
218
227
  this.fd = openSync(this.path, "a+", 0o600);
219
228
  this.boundary = this.sizeOrUnknown();
220
229
  }
@@ -223,8 +232,9 @@ export class AppendLog {
223
232
  e.code = "log_closed";
224
233
  throw e;
225
234
  }
226
- this.repairTornTail();
235
+ this.repairTail();
227
236
  const buf = Buffer.from(`${JSON.stringify(record)}\n`, "utf8");
237
+ const startedAt = this.boundary;
228
238
  let written = 0;
229
239
  try {
230
240
  while (written < buf.length) {
@@ -235,7 +245,7 @@ export class AppendLog {
235
245
  }
236
246
  }
237
247
  catch (err) {
238
- this.boundary = -1;
248
+ this.boundary = this.rollBackFailedWrite(startedAt, written) ? startedAt : -1;
239
249
  throw err;
240
250
  }
241
251
  if (this.boundary >= 0)
@@ -243,6 +253,19 @@ export class AppendLog {
243
253
  if (fsync)
244
254
  fsyncSync(this.fd);
245
255
  }
256
+ rollBackFailedWrite(startedAt, written) {
257
+ if (startedAt < 0)
258
+ return false;
259
+ try {
260
+ if (fstatSync(this.fd).size !== startedAt + written)
261
+ return false;
262
+ ftruncateSync(this.fd, startedAt);
263
+ return true;
264
+ }
265
+ catch {
266
+ return false;
267
+ }
268
+ }
246
269
  close() {
247
270
  this.disposed = true;
248
271
  this.releaseFd();
@@ -262,20 +285,23 @@ export class AppendLog {
262
285
  }
263
286
  }
264
287
  }
265
- function truncateTornTail(path) {
266
- let raw;
288
+ function repairJsonlTail(path) {
289
+ let bytes;
267
290
  try {
268
- raw = readFileSync(path, "utf8");
291
+ bytes = readFileSync(path);
269
292
  }
270
293
  catch (err) {
271
294
  if (err.code === "ENOENT")
272
295
  return;
273
296
  throw err;
274
297
  }
275
- if (raw === "" || raw.endsWith("\n"))
298
+ if (bytes.length === 0 || bytes[bytes.length - 1] === 0x0a)
276
299
  return;
277
- const lastNl = raw.lastIndexOf("\n");
278
- truncateSync(path, lastNl === -1 ? 0 : Buffer.byteLength(raw.slice(0, lastNl + 1), "utf8"));
300
+ const segmentStart = bytes.lastIndexOf(0x0a) + 1;
301
+ if (jsonlTextFormOf(bytes.toString("utf8", segmentStart)).form === "record")
302
+ appendFileSync(path, "\n");
303
+ else
304
+ truncateSync(path, segmentStart);
279
305
  }
280
306
  export class FileStoreLockError extends Error {
281
307
  code;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/core",
3
- "version": "7.38.1",
3
+ "version": "7.38.2",
4
4
  "description": "Stateless, task-oriented AI agent core",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",
@@ -0,0 +1,26 @@
1
+ {
2
+ "repo": "core",
3
+ "version": "7.38.2",
4
+ "from": "a8493dd6305fe6f8b3fea1174b56c30667f3ff1b",
5
+ "to": "201b3b096bc8086ff09caca61683c69acdc8bcbe",
6
+ "added": [],
7
+ "removed": [],
8
+ "changed": [],
9
+ "narrowed": [],
10
+ "closedSetMembers": [],
11
+ "closedSetsUnreadable": [],
12
+ "typeKeys": [],
13
+ "typeKeysUnreadableArms": [],
14
+ "typeKeysFlattenedCount": 220,
15
+ "declarationMoves": [],
16
+ "mintMoves": [],
17
+ "criteriaObserves": [],
18
+ "consumersHint": {
19
+ "cli": [],
20
+ "server": [],
21
+ "sdk": [],
22
+ "note": "scanned cli@64a76326 server@61d9a1d1 sdk@925ffec — a name listed is MENTIONED there today, which is evidence of consumption, not proof of it"
23
+ },
24
+ "breakingCount": 0,
25
+ "breakingItems": []
26
+ }