pi-crew 0.11.22 → 0.11.23
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 +11 -0
- package/dist/index.mjs +467 -467
- package/package.json +1 -1
- package/src/extension/team-tool/respond.ts +8 -6
- package/src/extension/team-tool/run.ts +4 -4
- package/src/prompt/surface-worker.ts +13 -1
- package/src/runtime/broker/crew-broker.ts +9 -20
- package/src/runtime/broker/protocol/request-parsers.ts +68 -0
- package/src/runtime/crew-agent-records.ts +25 -13
- package/src/runtime/dispatch-batch.ts +9 -6
- package/src/runtime/process/zombie-scanner.ts +6 -2
- package/src/runtime/scheduling/run-coalesced-task-group.ts +9 -3
- package/src/runtime/stale-reconciler.ts +4 -1
- package/src/state/event-log/cursor.ts +14 -2
package/package.json
CHANGED
|
@@ -327,12 +327,14 @@ export function handleRespond(params: TeamToolParamsValue, ctx: TeamContext): Pi
|
|
|
327
327
|
if (requeuedIds.length > 0) {
|
|
328
328
|
try {
|
|
329
329
|
const existingRuntimes = new Map(readCrewAgents(fresh.manifest).map((a) => [a.taskId, a.runtime]));
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
330
|
+
// R4-A1 (fix): merge requeued records into the existing index — the
|
|
331
|
+
// subset-only save wiped sibling agent records (team-runner.ts:525 pattern).
|
|
332
|
+
const mergedById = new Map(readCrewAgents(fresh.manifest).map((record) => [record.id, record] as const));
|
|
333
|
+
for (const task of updatedTasks.filter((task) => requeuedIds.includes(task.id))) {
|
|
334
|
+
const record = recordFromTask(fresh.manifest, task, existingRuntimes.get(task.id) ?? "child-process");
|
|
335
|
+
mergedById.set(record.id, record);
|
|
336
|
+
}
|
|
337
|
+
saveCrewAgents(fresh.manifest, [...mergedById.values()]);
|
|
336
338
|
} catch (error) {
|
|
337
339
|
logInternalError("team-tool.handleRespond.crewAgents", error, `runId=${fresh.manifest.runId}`);
|
|
338
340
|
}
|
|
@@ -137,7 +137,7 @@ function scheduleBackgroundEarlyExitGuard(cwd: string, runId: string, pid: numbe
|
|
|
137
137
|
runId: failed.runId,
|
|
138
138
|
message,
|
|
139
139
|
data: { pid, detail: liveness.detail },
|
|
140
|
-
});
|
|
140
|
+
}).catch((error) => logInternalError("team-tool.run.asyncFailedEvent", error, `runId=${failed.runId}`)); // R5-M-B3 (fix)
|
|
141
141
|
});
|
|
142
142
|
} catch (error) {
|
|
143
143
|
// One-shot unref'd timer — a lock-contention error must not escape and
|
|
@@ -589,7 +589,7 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
|
|
|
589
589
|
envTeams: getCrewEnv("PI_TEAMS_EXECUTE_WORKERS"),
|
|
590
590
|
},
|
|
591
591
|
},
|
|
592
|
-
});
|
|
592
|
+
}).catch((error) => logInternalError("team-tool.run.blockedEvent", error, `runId=${blocked.runId}`)); // R5-M-B3 (fix)
|
|
593
593
|
unregisterActiveRun(blocked.runId);
|
|
594
594
|
return result(
|
|
595
595
|
[
|
|
@@ -622,7 +622,7 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
|
|
|
622
622
|
type: "async.spawned",
|
|
623
623
|
runId: effectiveManifest.runId,
|
|
624
624
|
data: { pid: spawned.pid, logPath: spawned.logPath },
|
|
625
|
-
});
|
|
625
|
+
}).catch((error) => logInternalError("team-tool.run.asyncSpawnedEvent", error, `runId=${effectiveManifest.runId}`)); // R5-M-B3 (fix)
|
|
626
626
|
ctx.onRunStarted?.(effectiveManifest.runId);
|
|
627
627
|
scheduleBackgroundEarlyExitGuard(resolvedCtx.cwd, effectiveManifest.runId, spawned.pid, spawned.logPath);
|
|
628
628
|
// CORE-8: unified deadline for waitForRun timeout (background process is detached).
|
|
@@ -684,7 +684,7 @@ export async function handleRun(params: TeamToolParamsValue, ctx: TeamContext):
|
|
|
684
684
|
envTeams: getCrewEnv("PI_TEAMS_EXECUTE_WORKERS"),
|
|
685
685
|
},
|
|
686
686
|
},
|
|
687
|
-
});
|
|
687
|
+
}).catch((error) => logInternalError("team-tool.run.blockedEvent", error, `runId=${blocked.runId}`)); // R5-M-B3 (fix)
|
|
688
688
|
unregisterActiveRun(blocked.runId);
|
|
689
689
|
return result(
|
|
690
690
|
[
|
|
@@ -142,11 +142,23 @@ export function parentAlive(pid: number, expectedStartTime: string | undefined,
|
|
|
142
142
|
}
|
|
143
143
|
});
|
|
144
144
|
let stat: string | undefined;
|
|
145
|
+
let readerThrew = false;
|
|
145
146
|
try {
|
|
146
147
|
stat = reader(pid);
|
|
147
148
|
} catch {
|
|
148
|
-
|
|
149
|
+
// R7-P1 guard-gate fix (deep-review round 7/9): a THROWING/unreadable
|
|
150
|
+
// stat reader (macOS-shaped: /proc absent → read error) with NO
|
|
151
|
+
// recorded expectedStartTime cannot PROVE the parent died. The doc
|
|
152
|
+
// contract above ("Never kills on … an unreadable clock field — a
|
|
153
|
+
// false 'alive' falls back to the host watchdog") demands the
|
|
154
|
+
// alive-with-caveat tolerance here; the pre-fix code fell through to
|
|
155
|
+
// `return false` BEFORE the tolerance check and killed healthy
|
|
156
|
+
// workers at the first 5s tick.
|
|
157
|
+
readerThrew = true;
|
|
149
158
|
}
|
|
159
|
+
if (readerThrew) return expectedStartTime ? false : true;
|
|
160
|
+
// Reader returned "no entry" (default /proc reader ENOENT = dead on
|
|
161
|
+
// Linux) — dead-parent detection stays intact.
|
|
150
162
|
if (stat === undefined) return false;
|
|
151
163
|
if (!expectedStartTime) return true;
|
|
152
164
|
const observed = procStartTimeTicks(stat);
|
|
@@ -73,6 +73,7 @@ import {
|
|
|
73
73
|
BROKER_PROTOCOL,
|
|
74
74
|
isHelloParams,
|
|
75
75
|
isRequestObject,
|
|
76
|
+
parseDelegateRequestParams,
|
|
76
77
|
parseMsgSendParams,
|
|
77
78
|
parseWaitRequestParams,
|
|
78
79
|
safeStringify,
|
|
@@ -1455,28 +1456,16 @@ export class CrewBroker {
|
|
|
1455
1456
|
this.sendError(conn, id, "forbidden", "delegate requires a task-scoped token; re-dispatch with PI_CREW_BROKER_TASK_ID");
|
|
1456
1457
|
return;
|
|
1457
1458
|
}
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
timeoutSec?: unknown;
|
|
1466
|
-
};
|
|
1467
|
-
if (typeof p.prompt !== "string" || p.prompt.trim().length === 0) {
|
|
1468
|
-
this.sendError(conn, id, "bad-params", "delegate.request: 'prompt' (non-empty string) is required");
|
|
1459
|
+
// R7-H1 fix: parse params through the protocol-level parser (mirrors
|
|
1460
|
+
// parseWaitRequestParams discipline) — non-integer/non-finite/negative/
|
|
1461
|
+
// over-ceiling maxTurns/budgetTokens never reach admission or the
|
|
1462
|
+
// budget reservation (durable-negative tokensSpent escape).
|
|
1463
|
+
const parsed = parseDelegateRequestParams(params);
|
|
1464
|
+
if (!parsed) {
|
|
1465
|
+
this.sendError(conn, id, "bad-params", "delegate.request: invalid params");
|
|
1469
1466
|
return;
|
|
1470
1467
|
}
|
|
1471
|
-
const requested =
|
|
1472
|
-
prompt: p.prompt,
|
|
1473
|
-
...(typeof p.description === "string" ? { description: p.description } : {}),
|
|
1474
|
-
...(typeof p.role === "string" ? { role: p.role } : {}),
|
|
1475
|
-
...(typeof p.model === "string" ? { model: p.model } : {}),
|
|
1476
|
-
...(typeof p.maxTurns === "number" ? { maxTurns: p.maxTurns } : {}),
|
|
1477
|
-
...(typeof p.budgetTokens === "number" ? { budgetTokens: p.budgetTokens } : {}),
|
|
1478
|
-
...(typeof p.timeoutSec === "number" ? { timeoutSec: p.timeoutSec } : {}),
|
|
1479
|
-
};
|
|
1468
|
+
const requested = parsed;
|
|
1480
1469
|
const cwd = this.options.cwd;
|
|
1481
1470
|
if (!cwd) {
|
|
1482
1471
|
this.sendError(conn, id, "no-manifest", "broker has no cwd configured");
|
|
@@ -176,6 +176,74 @@ export function parseWaitRequestParams(value: unknown): WaitRequestParams | unde
|
|
|
176
176
|
};
|
|
177
177
|
}
|
|
178
178
|
|
|
179
|
+
// ============================================================================
|
|
180
|
+
// R7-H1 fix (deep-review round 8): delegate.request parameter parser.
|
|
181
|
+
// Mirrors parseWaitRequestParams discipline — the handler previously spread
|
|
182
|
+
// `typeof x === "number"` values straight into `requested`, so non-integer,
|
|
183
|
+
// non-finite, negative and over-ceiling maxTurns/budgetTokens reached
|
|
184
|
+
// admission (e.g. raw-wire -1e9 budget → durable negative tokensSpent;
|
|
185
|
+
// 1e999 → Infinity maxTurns → dead child-pi turn caps).
|
|
186
|
+
// ============================================================================
|
|
187
|
+
|
|
188
|
+
/** Server-side ceiling for grandchild turn caps (client tool schema: 1..200). */
|
|
189
|
+
export const DELEGATE_MAX_TURNS_CEILING = 200;
|
|
190
|
+
/** Server-side ceiling for grandchild token budgets (client schema: <=100M). */
|
|
191
|
+
export const DELEGATE_BUDGET_TOKENS_CEILING = 100_000_000;
|
|
192
|
+
/** Bounded delegate prompt (defense-in-depth under the frame cap). */
|
|
193
|
+
export const DELEGATE_PROMPT_MAX_CHARS = 100_000;
|
|
194
|
+
|
|
195
|
+
export interface DelegateRequestParams {
|
|
196
|
+
prompt: string;
|
|
197
|
+
description?: string;
|
|
198
|
+
role?: string;
|
|
199
|
+
model?: string;
|
|
200
|
+
maxTurns?: number;
|
|
201
|
+
budgetTokens?: number;
|
|
202
|
+
timeoutSec?: number;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function parseDelegateRequestParams(value: unknown): DelegateRequestParams | undefined {
|
|
206
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
207
|
+
const v = value as Record<string, unknown>;
|
|
208
|
+
if (typeof v.prompt !== "string" || v.prompt.trim().length === 0 || v.prompt.length > DELEGATE_PROMPT_MAX_CHARS) {
|
|
209
|
+
return undefined;
|
|
210
|
+
}
|
|
211
|
+
if (v.description !== undefined && typeof v.description !== "string") return undefined;
|
|
212
|
+
if (v.role !== undefined && !(typeof v.role === "string" && ["explorer", "analyst", "executor"].includes(v.role))) {
|
|
213
|
+
return undefined;
|
|
214
|
+
}
|
|
215
|
+
if (v.model !== undefined && typeof v.model !== "string") return undefined;
|
|
216
|
+
if (v.maxTurns !== undefined) {
|
|
217
|
+
if (typeof v.maxTurns !== "number" || !Number.isInteger(v.maxTurns) || v.maxTurns < 1 || v.maxTurns > DELEGATE_MAX_TURNS_CEILING) {
|
|
218
|
+
return undefined;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (v.budgetTokens !== undefined) {
|
|
222
|
+
if (
|
|
223
|
+
typeof v.budgetTokens !== "number" ||
|
|
224
|
+
!Number.isInteger(v.budgetTokens) || // R8 lane-B correction: client Type.Integer parity (0.5/3.5 pass isFinite)
|
|
225
|
+
v.budgetTokens < 0 ||
|
|
226
|
+
v.budgetTokens > DELEGATE_BUDGET_TOKENS_CEILING
|
|
227
|
+
) {
|
|
228
|
+
return undefined;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
// timeoutSec stays finite-only here; the 1..MAX range enforcement remains
|
|
232
|
+
// in spawn-policy §7 (evaluateDelegateAdmission).
|
|
233
|
+
if (v.timeoutSec !== undefined && (typeof v.timeoutSec !== "number" || !Number.isFinite(v.timeoutSec))) {
|
|
234
|
+
return undefined;
|
|
235
|
+
}
|
|
236
|
+
return {
|
|
237
|
+
prompt: v.prompt,
|
|
238
|
+
...(typeof v.description === "string" ? { description: v.description } : {}),
|
|
239
|
+
...(typeof v.role === "string" ? { role: v.role } : {}),
|
|
240
|
+
...(typeof v.model === "string" ? { model: v.model } : {}),
|
|
241
|
+
...(v.maxTurns !== undefined ? { maxTurns: v.maxTurns as number } : {}),
|
|
242
|
+
...(v.budgetTokens !== undefined ? { budgetTokens: v.budgetTokens as number } : {}),
|
|
243
|
+
...(v.timeoutSec !== undefined ? { timeoutSec: v.timeoutSec as number } : {}),
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
179
247
|
export interface WaitResolveParams {
|
|
180
248
|
to: string;
|
|
181
249
|
questionId: string;
|
|
@@ -698,6 +698,21 @@ function writeSeqToSidecar(filePath: string, seq: number): void {
|
|
|
698
698
|
}
|
|
699
699
|
}
|
|
700
700
|
|
|
701
|
+
function scanMaxAgentEventSeq(filePath: string): number {
|
|
702
|
+
let max = 0;
|
|
703
|
+
for (const line of fs.readFileSync(filePath, "utf-8").split(/\r?\n/)) {
|
|
704
|
+
if (!line.trim()) continue;
|
|
705
|
+
try {
|
|
706
|
+
const parsed = JSON.parse(line) as { seq?: unknown };
|
|
707
|
+
if (typeof parsed.seq === "number" && Number.isFinite(parsed.seq)) max = Math.max(max, parsed.seq);
|
|
708
|
+
else max += 1;
|
|
709
|
+
} catch {
|
|
710
|
+
max += 1;
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
return max;
|
|
714
|
+
}
|
|
715
|
+
|
|
701
716
|
function nextAgentEventSeq(filePath: string): number {
|
|
702
717
|
if (!fs.existsSync(filePath)) {
|
|
703
718
|
// Clean up stale sidecar when main file is gone.
|
|
@@ -714,24 +729,21 @@ function nextAgentEventSeq(filePath: string): number {
|
|
|
714
729
|
// FIX: Try sidecar file for O(1) lookup before falling back to O(n) scan.
|
|
715
730
|
const sidecarSeq = readSeqFromSidecar(filePath);
|
|
716
731
|
if (sidecarSeq !== undefined) {
|
|
732
|
+
// FS1 (fix, EL-1 port from event-log sequence-cache.ts): a REGRESSED
|
|
733
|
+
// sidecar (crash between append and sidecar advance, or lost best-effort
|
|
734
|
+
// durability) must never re-mint used seqs — trust it only as
|
|
735
|
+
// max(sidecar, tail-scan), else cursor consumers (seq > sinceSeq)
|
|
736
|
+
// permanently drop the duplicate-seq events.
|
|
737
|
+
const safeSeq = Math.max(sidecarSeq, scanMaxAgentEventSeq(filePath));
|
|
717
738
|
setAgentEventSeqCache(filePath, {
|
|
718
739
|
size: stat.size,
|
|
719
740
|
mtimeMs: stat.mtimeMs,
|
|
720
|
-
seq:
|
|
741
|
+
seq: safeSeq,
|
|
721
742
|
});
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
let max = 0;
|
|
725
|
-
for (const line of fs.readFileSync(filePath, "utf-8").split(/\r?\n/)) {
|
|
726
|
-
if (!line.trim()) continue;
|
|
727
|
-
try {
|
|
728
|
-
const parsed = JSON.parse(line) as { seq?: unknown };
|
|
729
|
-
if (typeof parsed.seq === "number" && Number.isFinite(parsed.seq)) max = Math.max(max, parsed.seq);
|
|
730
|
-
else max += 1;
|
|
731
|
-
} catch {
|
|
732
|
-
max += 1;
|
|
733
|
-
}
|
|
743
|
+
if (safeSeq !== sidecarSeq) writeSeqToSidecar(filePath, safeSeq);
|
|
744
|
+
return safeSeq + 1;
|
|
734
745
|
}
|
|
746
|
+
const max = scanMaxAgentEventSeq(filePath);
|
|
735
747
|
setAgentEventSeqCache(filePath, {
|
|
736
748
|
size: stat.size,
|
|
737
749
|
mtimeMs: stat.mtimeMs,
|
|
@@ -202,12 +202,15 @@ export async function sweepExpiredWaitingTasks(
|
|
|
202
202
|
}
|
|
203
203
|
try {
|
|
204
204
|
const existingRuntimes = new Map(readCrewAgents(fresh.manifest).map((a) => [a.taskId, a.runtime]));
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
205
|
+
// R4-A1 (fix): agents.json is a full-index overwrite — merge the requeued
|
|
206
|
+
// records into the existing set (team-runner.ts:525 pattern) instead of
|
|
207
|
+
// persisting the subset alone (siblings incl. RUNNING agents were wiped).
|
|
208
|
+
const mergedById = new Map(readCrewAgents(fresh.manifest).map((record) => [record.id, record] as const));
|
|
209
|
+
for (const task of tasks.filter((t) => requeuedTaskIds.includes(t.id))) {
|
|
210
|
+
const record = recordFromTask(fresh.manifest, task, existingRuntimes.get(task.id) ?? "child-process");
|
|
211
|
+
mergedById.set(record.id, record);
|
|
212
|
+
}
|
|
213
|
+
saveCrewAgents(fresh.manifest, [...mergedById.values()]);
|
|
211
214
|
} catch (error) {
|
|
212
215
|
logInternalError("dispatch-batch.ask-timeout.crewAgents", error, `runId=${runId}`);
|
|
213
216
|
}
|
|
@@ -179,8 +179,12 @@ function isPidAlive(pid: number): boolean {
|
|
|
179
179
|
// process.kill(pid, 0) throws if the pid is not alive (or not ours).
|
|
180
180
|
process.kill(pid, 0);
|
|
181
181
|
return true;
|
|
182
|
-
} catch {
|
|
183
|
-
|
|
182
|
+
} catch (error) {
|
|
183
|
+
// R7-A2 (2026-10-17): only ESRCH/ENOENT mean the process is gone.
|
|
184
|
+
// EPERM means the process is alive but protected (different security
|
|
185
|
+
// context); treat as alive — mirror of active-run-registry.ts fix.
|
|
186
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
187
|
+
return code !== "ESRCH" && code !== "ENOENT";
|
|
184
188
|
}
|
|
185
189
|
}
|
|
186
190
|
|
|
@@ -61,7 +61,9 @@ export async function runCoalescedTaskGroup(input: CoalescedTaskGroupInput): Pro
|
|
|
61
61
|
}
|
|
62
62
|
return t;
|
|
63
63
|
});
|
|
64
|
-
|
|
64
|
+
// R3-H1 (fix): fresh-read group-scoped merge — never persist the dispatch-time
|
|
65
|
+
// full-array snapshot over concurrent sibling state changes.
|
|
66
|
+
await repairGroupTerminalWrite(manifest, taskIds, updatedTasks);
|
|
65
67
|
await appendEventAsync(manifest.eventsPath, {
|
|
66
68
|
type: "task.coalesced_dispatch_start",
|
|
67
69
|
runId: manifest.runId,
|
|
@@ -86,7 +88,9 @@ export async function runCoalescedTaskGroup(input: CoalescedTaskGroupInput): Pro
|
|
|
86
88
|
heartbeat: t.heartbeat ?? createWorkerHeartbeat(t.id),
|
|
87
89
|
};
|
|
88
90
|
});
|
|
89
|
-
|
|
91
|
+
// R3-H1 (fix): fresh-read group-scoped merge — never persist the dispatch-time
|
|
92
|
+
// full-array snapshot over concurrent sibling state changes.
|
|
93
|
+
await repairGroupTerminalWrite(manifest, taskIds, updatedTasks);
|
|
90
94
|
|
|
91
95
|
let rawOutput = "";
|
|
92
96
|
let success = false;
|
|
@@ -398,7 +402,9 @@ export async function runCoalescedTaskGroup(input: CoalescedTaskGroupInput): Pro
|
|
|
398
402
|
if (drainTimeout !== undefined) clearTimeout(drainTimeout);
|
|
399
403
|
}
|
|
400
404
|
}
|
|
401
|
-
|
|
405
|
+
// R3-H1 (fix): fresh-read group-scoped merge — never persist the dispatch-time
|
|
406
|
+
// full-array snapshot over concurrent sibling state changes.
|
|
407
|
+
await repairGroupTerminalWrite(manifest, taskIds, updatedTasks);
|
|
402
408
|
let updatedManifest: TeamRunManifest = {
|
|
403
409
|
...manifest,
|
|
404
410
|
artifacts: mergeArtifacts([...manifest.artifacts, ...newArtifacts]),
|
|
@@ -713,7 +713,10 @@ export function reconcileOrphanedTempWorkspaces(
|
|
|
713
713
|
const result = reconcileStaleRun(manifest, tasks, now);
|
|
714
714
|
if (result.repaired && result.repairedTasks) {
|
|
715
715
|
// Persist repaired tasks (atomic — temp+rename to survive mid-write crash)
|
|
716
|
-
|
|
716
|
+
// R2-H1 (fix): persist the FULL array (NEW-1 persistTasks), not the filtered
|
|
717
|
+
// stale subset — the subset write destroyed every sibling record (mirror
|
|
718
|
+
// crash-recovery.ts:1066).
|
|
719
|
+
atomicWriteJson(tasksPath, result.persistTasks ?? result.repairedTasks);
|
|
717
720
|
// Update manifest status
|
|
718
721
|
const updated = {
|
|
719
722
|
...manifest,
|
|
@@ -522,7 +522,15 @@ function cursorResultFromEvents(
|
|
|
522
522
|
// streaming consumers. No-rotation case: no archives exist → behavior is
|
|
523
523
|
// byte-identical to before (mergeArchiveTailEvents returns liveEvents).
|
|
524
524
|
const merged = mergeArchiveTailEvents(readArchiveTailEvents(eventsPath, sinceSeq), filtered);
|
|
525
|
-
|
|
525
|
+
// FS3 (fix): seq-sort before the limit slice. File-order slicing on a
|
|
526
|
+
// documented non-monotonic log can deliver an early high seq inside the
|
|
527
|
+
// first `limit` events; nextSeq=max-delivered then permanently skips every
|
|
528
|
+
// undelivered lower-seq event for paging consumers resuming from nextSeq.
|
|
529
|
+
// Sort ONLY under a limit: the no-limit path delivers every event (nextSeq
|
|
530
|
+
// = true max ⇒ nothing skippable) and keeps its historical FILE-order
|
|
531
|
+
// contract (pinned by event-log-torn-poison.test.ts AC7, U22 Phase 1).
|
|
532
|
+
const events =
|
|
533
|
+
limit !== undefined ? [...merged].sort((a, b) => (a.metadata?.seq ?? 0) - (b.metadata?.seq ?? 0)).slice(0, limit) : merged;
|
|
526
534
|
const returnedMaxSeq = events.reduce((max, event) => Math.max(max, event.metadata?.seq ?? 0), sinceSeq);
|
|
527
535
|
return {
|
|
528
536
|
events,
|
|
@@ -782,7 +790,11 @@ export function readEventsCursor(eventsPath: string, options: EventCursorOptions
|
|
|
782
790
|
const filtered = safeItems.filter((event) => (event.metadata?.seq ?? 0) > sinceSeq);
|
|
783
791
|
const merged = mergeArchiveTailEvents(drainedArchiveEvents, filtered);
|
|
784
792
|
const limit = positiveInteger(options.limit);
|
|
785
|
-
|
|
793
|
+
// FS3 (fix): seq-sort before the limit slice — see cursorResultFromEvents.
|
|
794
|
+
// Sort ONLY under a limit (no-limit keeps the historical file-order
|
|
795
|
+
// contract; nothing is skippable when everything is delivered).
|
|
796
|
+
const events =
|
|
797
|
+
limit !== undefined ? [...merged].sort((a, b) => (a.metadata?.seq ?? 0) - (b.metadata?.seq ?? 0)).slice(0, limit) : merged;
|
|
786
798
|
const returnedMaxSeq = events.reduce((max, event) => Math.max(max, event.metadata?.seq ?? 0), sinceSeq);
|
|
787
799
|
// BR-08: `read.state.byteOffset` sits past the ENTIRE delta read above —
|
|
788
800
|
// including the events the `limit` slice just dropped. Handing that back
|