@wrongstack/core 0.302.2 → 0.305.0
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/README.md +1 -1
- package/dist/chronicle/index.js +117 -30
- package/dist/chronicle/project-server.js +12 -5
- package/dist/coordination/agents/index.js +4645 -3448
- package/dist/coordination/agents/project-agent-auto-optimize.d.ts +116 -0
- package/dist/coordination/agents/project-agent-capture-window.d.ts +29 -0
- package/dist/coordination/agents/project-agent-config-io.d.ts +11 -0
- package/dist/coordination/agents/project-agent-consolidation.d.ts +34 -2
- package/dist/coordination/agents/project-agent-directive-outcome.d.ts +57 -0
- package/dist/coordination/agents/project-agent-files.d.ts +12 -3
- package/dist/coordination/agents/project-agent-identity-types.d.ts +4 -0
- package/dist/coordination/agents/project-agent-identity.d.ts +38 -11
- package/dist/coordination/agents/project-agent-learning-entries.d.ts +8 -2
- package/dist/coordination/agents/project-agent-learning-policy.d.ts +22 -1
- package/dist/coordination/agents/project-agent-learning-structured.d.ts +72 -1
- package/dist/coordination/agents/project-agent-optimizer.d.ts +49 -0
- package/dist/coordination/agents/project-agent-quarantine.d.ts +63 -0
- package/dist/coordination/agents/project-agent-skill-layer.d.ts +146 -0
- package/dist/coordination/agents/role-skills.d.ts +11 -1
- package/dist/coordination/agents/types.d.ts +10 -2
- package/dist/coordination/director-prompts.d.ts +19 -6
- package/dist/coordination/director-tools.d.ts +2 -2
- package/dist/coordination/fleet.d.ts +0 -6
- package/dist/coordination/index.d.ts +1 -1
- package/dist/coordination/index.js +5746 -4326
- package/dist/coordination/mail-tools.d.ts +1 -1
- package/dist/core/agent-types.d.ts +4 -2
- package/dist/core/agent.d.ts +1 -0
- package/dist/core/context.d.ts +19 -0
- package/dist/core/conversation-state.d.ts +14 -0
- package/dist/core/fallback-profile-manager.d.ts +70 -2
- package/dist/core/index.js +317 -108
- package/dist/core/system-prompt-blocks.d.ts +1 -1
- package/dist/core/system-prompt-builder.d.ts +13 -1
- package/dist/core/system-prompt-glossary.d.ts +73 -0
- package/dist/core/system-prompt-memory-skills.d.ts +2 -2
- package/dist/defaults/index.js +1635 -1120
- package/dist/execution/council-orchestrator.d.ts +3 -13
- package/dist/execution/index.js +3136 -2706
- package/dist/execution/one-shot-llm.d.ts +5 -0
- package/dist/goal/index.js +7 -0
- package/dist/hq/index.js +17 -7
- package/dist/hq/protocol/kanban.d.ts +21 -0
- package/dist/hq/protocol.js +5 -1
- package/dist/hq/redaction.d.ts +14 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +5502 -3483
- package/dist/infrastructure/index.js +247 -122
- package/dist/kernel/events/agent-events.d.ts +28 -0
- package/dist/plugin/index.js +111 -7
- package/dist/registry/index.js +11 -0
- package/dist/registry/tool-registry.d.ts +8 -0
- package/dist/replay/hash.d.ts +9 -0
- package/dist/replay/index.js +14 -4
- package/dist/replay/replay-provider-runner.d.ts +31 -1
- package/dist/security/index.js +92 -21
- package/dist/security/kanban-boundary.d.ts +5 -1
- package/dist/security/secret-vault.d.ts +2 -0
- package/dist/session-catalog/index.js +86 -10
- package/dist/session-catalog/project-server.js +100 -17
- package/dist/session-catalog/protocol.d.ts +20 -4
- package/dist/session-catalog/store.d.ts +19 -3
- package/dist/storage/index.js +305 -69
- package/dist/storage/memory-consolidator.d.ts +4 -2
- package/dist/storage/plan-store.d.ts +1 -1
- package/dist/storage/session-resume-validation.d.ts +24 -0
- package/dist/storage/session-store/directory-scan.d.ts +5 -1
- package/dist/storage/session-store/fork-session.d.ts +13 -1
- package/dist/storage/session-store/load-cache.d.ts +11 -0
- package/dist/storage/session-store/prune-helpers.d.ts +5 -0
- package/dist/storage/session-store.d.ts +18 -0
- package/dist/tasking/index.js +5 -0
- package/dist/tools/index.js +1151 -831
- package/dist/types/config/mcp-features.d.ts +31 -1
- package/dist/types/config/root.d.ts +23 -1
- package/dist/types/config/skills-fleet-brain.d.ts +34 -0
- package/dist/types/config/tools.d.ts +22 -0
- package/dist/types/config/ui.d.ts +14 -0
- package/dist/types/config.d.ts +1 -0
- package/dist/types/default-config.d.ts +1 -0
- package/dist/types/index.d.ts +2 -2
- package/dist/types/index.js +23 -0
- package/dist/types/multi-agent.d.ts +7 -0
- package/dist/types/session.d.ts +9 -1
- package/dist/types/task-graph.d.ts +2 -0
- package/dist/types/tool-executor.d.ts +2 -0
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.js +217 -76
- package/dist/utils/project-state-guard.d.ts +21 -0
- package/dist/utils/session-scoped-path.d.ts +17 -0
- package/dist/utils/todos-format.d.ts +20 -0
- package/instructions/leader-after-task.md +3 -4
- package/instructions/system-lite.md +17 -14
- package/instructions/system-pro.md +29 -30
- package/instructions/system.md +29 -28
- package/package.json +3 -3
- package/skills/wrongstack-kanban/SKILL.md +95 -93
package/dist/storage/index.js
CHANGED
|
@@ -2152,15 +2152,51 @@ var atomicReplaceWithWriter = primitives.atomicReplaceWithWriter;
|
|
|
2152
2152
|
var ensureDir = primitives.ensureDir;
|
|
2153
2153
|
var withFileLock = primitives.withFileLock;
|
|
2154
2154
|
|
|
2155
|
+
// src/utils/session-scoped-path.ts
|
|
2156
|
+
import * as path6 from "node:path";
|
|
2157
|
+
function sessionScopedPath(dir, sessionId, suffix) {
|
|
2158
|
+
if (!sessionId || sessionId.includes("\\") || sessionId.includes("..")) {
|
|
2159
|
+
throw invalid(sessionId);
|
|
2160
|
+
}
|
|
2161
|
+
const resolved = path6.resolve(dir, `${sessionId}${suffix}`);
|
|
2162
|
+
const rel = path6.relative(path6.resolve(dir), resolved);
|
|
2163
|
+
if (rel.startsWith("..") || path6.isAbsolute(rel)) {
|
|
2164
|
+
throw invalid(sessionId);
|
|
2165
|
+
}
|
|
2166
|
+
return resolved;
|
|
2167
|
+
}
|
|
2168
|
+
var SESSION_SIDECAR_JSONL_SUFFIXES = [
|
|
2169
|
+
".replay.jsonl",
|
|
2170
|
+
".audit.jsonl",
|
|
2171
|
+
".annotations.jsonl"
|
|
2172
|
+
];
|
|
2173
|
+
var RESERVED_SESSION_JSONL_NAMES = /* @__PURE__ */ new Set([
|
|
2174
|
+
"_index.jsonl",
|
|
2175
|
+
"_mailbox.jsonl"
|
|
2176
|
+
]);
|
|
2177
|
+
function isSessionTranscriptFileName(name) {
|
|
2178
|
+
if (!name.endsWith(".jsonl")) return false;
|
|
2179
|
+
if (RESERVED_SESSION_JSONL_NAMES.has(name)) return false;
|
|
2180
|
+
return !SESSION_SIDECAR_JSONL_SUFFIXES.some((suffix) => name.endsWith(suffix));
|
|
2181
|
+
}
|
|
2182
|
+
function invalid(sessionId) {
|
|
2183
|
+
return new FsError({
|
|
2184
|
+
message: `Invalid sessionId: ${sessionId}`,
|
|
2185
|
+
code: ERROR_CODES.FS_DELETE_FAILED,
|
|
2186
|
+
path: sessionId,
|
|
2187
|
+
context: { reason: "path_traversal" }
|
|
2188
|
+
});
|
|
2189
|
+
}
|
|
2190
|
+
|
|
2155
2191
|
// src/session-registry.ts
|
|
2156
2192
|
import * as fs6 from "node:fs/promises";
|
|
2157
|
-
import * as
|
|
2193
|
+
import * as path8 from "node:path";
|
|
2158
2194
|
|
|
2159
2195
|
// src/session-registry-atomic-file.ts
|
|
2160
2196
|
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
2161
2197
|
import * as fs5 from "node:fs/promises";
|
|
2162
2198
|
import { hostname } from "node:os";
|
|
2163
|
-
import * as
|
|
2199
|
+
import * as path7 from "node:path";
|
|
2164
2200
|
var STALE_LOCK_MS = 1e4;
|
|
2165
2201
|
var SAME_HOST_STALE_MS = 2 * STALE_LOCK_MS;
|
|
2166
2202
|
var STALE_TMP_MS = 6e4;
|
|
@@ -2240,9 +2276,9 @@ async function breakLockAtomically(lockPath) {
|
|
|
2240
2276
|
return true;
|
|
2241
2277
|
}
|
|
2242
2278
|
async function writeAtomicFile(filePath, registry) {
|
|
2243
|
-
const tmp =
|
|
2244
|
-
|
|
2245
|
-
`.${
|
|
2279
|
+
const tmp = path7.join(
|
|
2280
|
+
path7.dirname(filePath),
|
|
2281
|
+
`.${path7.basename(filePath)}.${randomUUID2().slice(0, 8)}.tmp`
|
|
2246
2282
|
);
|
|
2247
2283
|
let tmpPersisted = false;
|
|
2248
2284
|
try {
|
|
@@ -2283,21 +2319,21 @@ async function writeAtomicFile(filePath, registry) {
|
|
|
2283
2319
|
}
|
|
2284
2320
|
async function pruneStaleTempFiles(filePath) {
|
|
2285
2321
|
try {
|
|
2286
|
-
const dir =
|
|
2287
|
-
const base =
|
|
2322
|
+
const dir = path7.dirname(filePath);
|
|
2323
|
+
const base = path7.basename(filePath);
|
|
2288
2324
|
const now = Date.now();
|
|
2289
2325
|
const stale = [];
|
|
2290
2326
|
for (const name of await fs5.readdir(dir)) {
|
|
2291
2327
|
const isTemp = (name.startsWith(`${base}.`) || name.startsWith(`.${base}.`)) && name.endsWith(".tmp");
|
|
2292
2328
|
if (!isTemp) continue;
|
|
2293
|
-
const stat15 = await fs5.stat(
|
|
2329
|
+
const stat15 = await fs5.stat(path7.join(dir, name)).catch(() => null);
|
|
2294
2330
|
if (!stat15) continue;
|
|
2295
2331
|
if (now - stat15.mtimeMs > STALE_TMP_MS) stale.push({ name, mtimeMs: stat15.mtimeMs });
|
|
2296
2332
|
}
|
|
2297
2333
|
stale.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
2298
2334
|
await Promise.all(
|
|
2299
2335
|
stale.slice(MAX_STALE_TMP_FILES).map(async ({ name }) => {
|
|
2300
|
-
await fs5.unlink(
|
|
2336
|
+
await fs5.unlink(path7.join(dir, name)).catch(() => void 0);
|
|
2301
2337
|
})
|
|
2302
2338
|
);
|
|
2303
2339
|
} catch {
|
|
@@ -2361,7 +2397,7 @@ var SessionRegistry = class {
|
|
|
2361
2397
|
lastEntry = null;
|
|
2362
2398
|
ownershipLockWaitMs;
|
|
2363
2399
|
constructor(globalRoot, options = {}) {
|
|
2364
|
-
this.filePath =
|
|
2400
|
+
this.filePath = path8.join(globalRoot, REGISTRY_FILE);
|
|
2365
2401
|
this.ownershipLockWaitMs = Math.max(0, options.ownershipLockWaitMs ?? OWNERSHIP_LOCK_WAIT_MS);
|
|
2366
2402
|
}
|
|
2367
2403
|
// ── Public API ──────────────────────────────────────────────────────────
|
|
@@ -2671,7 +2707,7 @@ var SessionRegistry = class {
|
|
|
2671
2707
|
const deadline = Date.now() + waitBudgetMs;
|
|
2672
2708
|
let attempt = 0;
|
|
2673
2709
|
try {
|
|
2674
|
-
await fs6.mkdir(
|
|
2710
|
+
await fs6.mkdir(path8.dirname(this.filePath), { recursive: true });
|
|
2675
2711
|
await this.maybePruneStaleTempFiles();
|
|
2676
2712
|
} catch (err) {
|
|
2677
2713
|
if (required) {
|
|
@@ -2815,28 +2851,6 @@ function expectDefined(value, label) {
|
|
|
2815
2851
|
return value;
|
|
2816
2852
|
}
|
|
2817
2853
|
|
|
2818
|
-
// src/utils/session-scoped-path.ts
|
|
2819
|
-
import * as path8 from "node:path";
|
|
2820
|
-
function sessionScopedPath(dir, sessionId, suffix) {
|
|
2821
|
-
if (!sessionId || sessionId.includes("\\") || sessionId.includes("..")) {
|
|
2822
|
-
throw invalid(sessionId);
|
|
2823
|
-
}
|
|
2824
|
-
const resolved = path8.resolve(dir, `${sessionId}${suffix}`);
|
|
2825
|
-
const rel = path8.relative(path8.resolve(dir), resolved);
|
|
2826
|
-
if (rel.startsWith("..") || path8.isAbsolute(rel)) {
|
|
2827
|
-
throw invalid(sessionId);
|
|
2828
|
-
}
|
|
2829
|
-
return resolved;
|
|
2830
|
-
}
|
|
2831
|
-
function invalid(sessionId) {
|
|
2832
|
-
return new FsError({
|
|
2833
|
-
message: `Invalid sessionId: ${sessionId}`,
|
|
2834
|
-
code: ERROR_CODES.FS_DELETE_FAILED,
|
|
2835
|
-
path: sessionId,
|
|
2836
|
-
context: { reason: "path_traversal" }
|
|
2837
|
-
});
|
|
2838
|
-
}
|
|
2839
|
-
|
|
2840
2854
|
// src/storage/annotations-store.ts
|
|
2841
2855
|
var FILE_VERSION = 1;
|
|
2842
2856
|
var MAX_TEXT_LENGTH = 2e3;
|
|
@@ -4628,6 +4642,9 @@ var DEFAULT_TOOLS_CONFIG = Object.freeze({
|
|
|
4628
4642
|
disabledTools: Object.freeze([]),
|
|
4629
4643
|
autoExtendLimit: true,
|
|
4630
4644
|
restrictToProjectRoot: true,
|
|
4645
|
+
// Off by default: the board is a record of the work, not a permit for it.
|
|
4646
|
+
// See ToolsConfig.kanbanGovernance for what turning it on costs and gates.
|
|
4647
|
+
kanbanGovernance: false,
|
|
4631
4648
|
loopDetection: Object.freeze({
|
|
4632
4649
|
mode: "steer-then-cut",
|
|
4633
4650
|
steerThreshold: 3,
|
|
@@ -4685,6 +4702,7 @@ var CONFIG_BEHAVIOR_DEFAULTS = {
|
|
|
4685
4702
|
disabledTools: DEFAULT_TOOLS_CONFIG.disabledTools,
|
|
4686
4703
|
autoExtendLimit: DEFAULT_TOOLS_CONFIG.autoExtendLimit,
|
|
4687
4704
|
restrictToProjectRoot: DEFAULT_TOOLS_CONFIG.restrictToProjectRoot,
|
|
4705
|
+
kanbanGovernance: DEFAULT_TOOLS_CONFIG.kanbanGovernance,
|
|
4688
4706
|
loopDetection: DEFAULT_TOOLS_CONFIG.loopDetection
|
|
4689
4707
|
},
|
|
4690
4708
|
log: { level: "info" },
|
|
@@ -5109,6 +5127,14 @@ var IN_PROJECT_DENIED_PATHS = [
|
|
|
5109
5127
|
{
|
|
5110
5128
|
path: "tools.restrictToProjectRoot",
|
|
5111
5129
|
reason: "The other half of the filesystem confinement switch."
|
|
5130
|
+
},
|
|
5131
|
+
{
|
|
5132
|
+
// Denied for the direction that loosens: the flag defaults to false, so a
|
|
5133
|
+
// repo can only ever use it to turn OFF a gate the user deliberately
|
|
5134
|
+
// enabled. Same class as `tools.restrictToProjectRoot` — a control the
|
|
5135
|
+
// operator owns, not the checked-out repository.
|
|
5136
|
+
path: "tools.kanbanGovernance",
|
|
5137
|
+
reason: "Repo-committed config could disable a Kanban governance gate the operator switched on, letting product mutations run outside any managed card."
|
|
5112
5138
|
}
|
|
5113
5139
|
];
|
|
5114
5140
|
function deleteNestedPath(target, path34) {
|
|
@@ -7682,10 +7708,38 @@ var SessionMemoryConsolidator = class {
|
|
|
7682
7708
|
process.stderr.write(`[memory] Session consolidation: ${added} added
|
|
7683
7709
|
`);
|
|
7684
7710
|
}
|
|
7711
|
+
if (this.Sage?.rememberSage) {
|
|
7712
|
+
try {
|
|
7713
|
+
const digestBody = buildSessionDigestText(_finalText, _iterations, added);
|
|
7714
|
+
if (digestBody) {
|
|
7715
|
+
const expires = new Date(Date.now() + 14 * 24 * 60 * 6e4).toISOString();
|
|
7716
|
+
await this.Sage.rememberSage({
|
|
7717
|
+
text: digestBody,
|
|
7718
|
+
scope: "session",
|
|
7719
|
+
kind: "session_digest",
|
|
7720
|
+
importance: 0.4,
|
|
7721
|
+
confidence: 0.65,
|
|
7722
|
+
persistence: "short_lived",
|
|
7723
|
+
tags: ["session_digest", "auto"],
|
|
7724
|
+
anchors: [],
|
|
7725
|
+
sources: [{ type: "session", sessionId: _sessionId }],
|
|
7726
|
+
ownerSessionId: _sessionId,
|
|
7727
|
+
expiresAt: expires
|
|
7728
|
+
});
|
|
7729
|
+
}
|
|
7730
|
+
} catch {
|
|
7731
|
+
}
|
|
7732
|
+
}
|
|
7685
7733
|
} catch {
|
|
7686
7734
|
}
|
|
7687
7735
|
};
|
|
7688
7736
|
};
|
|
7737
|
+
function buildSessionDigestText(finalText, iterations, factsAdded) {
|
|
7738
|
+
const cleaned = finalText.replace(/\s+/g, " ").trim();
|
|
7739
|
+
if (cleaned.length < 40) return void 0;
|
|
7740
|
+
const summary2 = cleaned.slice(0, 400);
|
|
7741
|
+
return `Session digest (${iterations} iter${factsAdded > 0 ? `, ${factsAdded} facts added` : ""}): ` + summary2 + (cleaned.length > 400 ? "\u2026" : "");
|
|
7742
|
+
}
|
|
7689
7743
|
|
|
7690
7744
|
// src/storage/memory-graph-backend.ts
|
|
7691
7745
|
import * as fs14 from "node:fs/promises";
|
|
@@ -8039,8 +8093,31 @@ function simpleHash(s) {
|
|
|
8039
8093
|
}
|
|
8040
8094
|
|
|
8041
8095
|
// src/storage/plan-store.ts
|
|
8042
|
-
import * as fsp5 from "node:fs/promises";
|
|
8043
8096
|
import { randomUUID as randomUUID5 } from "node:crypto";
|
|
8097
|
+
import * as fsp5 from "node:fs/promises";
|
|
8098
|
+
function assertPlanMutationInvariants(previous, updated) {
|
|
8099
|
+
const ids = updated.items.map((item) => item.id);
|
|
8100
|
+
const uniqueIds = new Set(ids);
|
|
8101
|
+
if (uniqueIds.size !== ids.length) {
|
|
8102
|
+
throw new SessionError({
|
|
8103
|
+
message: "Plan mutation rejected: plan item IDs must be unique.",
|
|
8104
|
+
code: "SESSION_WRITE_FAILED",
|
|
8105
|
+
sessionId: updated.sessionId,
|
|
8106
|
+
context: { operation: "mutatePlan", invariant: "unique_plan_item_ids" }
|
|
8107
|
+
});
|
|
8108
|
+
}
|
|
8109
|
+
const omittedUnfinished = previous.items.filter(
|
|
8110
|
+
(item) => item.status !== "done" && !uniqueIds.has(item.id)
|
|
8111
|
+
);
|
|
8112
|
+
if (omittedUnfinished.length > 0) {
|
|
8113
|
+
throw new SessionError({
|
|
8114
|
+
message: `Plan mutation rejected: unfinished items cannot be omitted: ${omittedUnfinished.map((item) => item.id).join(", ")}. Complete them first.`,
|
|
8115
|
+
code: "SESSION_WRITE_FAILED",
|
|
8116
|
+
sessionId: updated.sessionId,
|
|
8117
|
+
context: { operation: "mutatePlan", invariant: "unfinished_plan_coverage" }
|
|
8118
|
+
});
|
|
8119
|
+
}
|
|
8120
|
+
}
|
|
8044
8121
|
async function loadPlan(filePath, events) {
|
|
8045
8122
|
const t0 = Date.now();
|
|
8046
8123
|
let raw;
|
|
@@ -8222,7 +8299,9 @@ function deriveTodosFromPlanItem(plan, idOrIndex, subtasks) {
|
|
|
8222
8299
|
async function mutatePlan(filePath, sessionId, fn) {
|
|
8223
8300
|
return withFileLock(filePath, async () => {
|
|
8224
8301
|
const plan = await loadPlan(filePath) ?? emptyPlan(sessionId);
|
|
8302
|
+
const previous = structuredClone(plan);
|
|
8225
8303
|
const updated = await fn(plan);
|
|
8304
|
+
assertPlanMutationInvariants(previous, updated);
|
|
8226
8305
|
const persisted = await savePlan(filePath, updated);
|
|
8227
8306
|
if (!persisted) {
|
|
8228
8307
|
throw new SessionError({
|
|
@@ -9252,11 +9331,15 @@ function sortKeys(value) {
|
|
|
9252
9331
|
}
|
|
9253
9332
|
return value;
|
|
9254
9333
|
}
|
|
9334
|
+
function semanticMessage(message) {
|
|
9335
|
+
const { ts: _ts, _estTokens: _estimate, origin: _origin, ...semantic } = message;
|
|
9336
|
+
return semantic;
|
|
9337
|
+
}
|
|
9255
9338
|
function hashRequest(request) {
|
|
9256
9339
|
const payload = {
|
|
9257
9340
|
model: request.model,
|
|
9258
9341
|
system: request.system,
|
|
9259
|
-
messages: request.messages,
|
|
9342
|
+
messages: request.messages.map(semanticMessage),
|
|
9260
9343
|
tools: request.tools,
|
|
9261
9344
|
maxTokens: request.maxTokens,
|
|
9262
9345
|
temperature: request.temperature,
|
|
@@ -11005,9 +11088,6 @@ import * as path25 from "node:path";
|
|
|
11005
11088
|
function shouldSkipSessionDirectoryEntry(name) {
|
|
11006
11089
|
return name.startsWith(".") && name !== ".wrongstack" || name === "shared" || name === "subagents" || name === "attachments";
|
|
11007
11090
|
}
|
|
11008
|
-
function isSessionJsonlFileName(name) {
|
|
11009
|
-
return name.endsWith(".jsonl") && name !== "_index.jsonl";
|
|
11010
|
-
}
|
|
11011
11091
|
|
|
11012
11092
|
// src/storage/session-store/directory-session-files.ts
|
|
11013
11093
|
function sessionIdForFile(prefix, name) {
|
|
@@ -11030,7 +11110,7 @@ async function collectSessionFiles(dir, prefix = "", depth = 0) {
|
|
|
11030
11110
|
if (shouldSkipSessionDirectoryEntry(entry.name)) continue;
|
|
11031
11111
|
if (entry.isDirectory()) {
|
|
11032
11112
|
dirEntries.push(entry);
|
|
11033
|
-
} else if (entry.isFile() &&
|
|
11113
|
+
} else if (entry.isFile() && isSessionTranscriptFileName(entry.name)) {
|
|
11034
11114
|
files.push({ id: sessionIdForFile(prefix, entry.name), filePath: path25.join(dir, entry.name) });
|
|
11035
11115
|
}
|
|
11036
11116
|
}
|
|
@@ -11054,7 +11134,7 @@ async function collectSessionIds(dir, prefix = "", depth = 0) {
|
|
|
11054
11134
|
if (shouldSkipSessionDirectoryEntry(entry.name)) continue;
|
|
11055
11135
|
if (entry.isDirectory()) {
|
|
11056
11136
|
dirEntries.push(entry);
|
|
11057
|
-
} else if (entry.isFile() &&
|
|
11137
|
+
} else if (entry.isFile() && isSessionTranscriptFileName(entry.name)) {
|
|
11058
11138
|
fileIds.push(sessionIdForFile(prefix, entry.name));
|
|
11059
11139
|
}
|
|
11060
11140
|
}
|
|
@@ -11218,11 +11298,8 @@ var SessionRecovery = class _SessionRecovery {
|
|
|
11218
11298
|
}
|
|
11219
11299
|
continue;
|
|
11220
11300
|
}
|
|
11221
|
-
if (!entry.isFile() || !entry.name
|
|
11222
|
-
if (entry.name === "_index.jsonl" || entry.name === "_mailbox.jsonl") continue;
|
|
11301
|
+
if (!entry.isFile() || !isSessionTranscriptFileName(entry.name)) continue;
|
|
11223
11302
|
const base = entry.name.slice(0, -".jsonl".length);
|
|
11224
|
-
if (base.includes(".replay") || base.includes(".annotations") || base.includes(".audit"))
|
|
11225
|
-
continue;
|
|
11226
11303
|
const sessionId = prefix ? `${prefix}/${base}` : base;
|
|
11227
11304
|
const stale = await this.detectStaleExact(sessionId);
|
|
11228
11305
|
if (stale) out.push(stale);
|
|
@@ -12580,6 +12657,15 @@ async function validateResumeFileObservations(events, projectRoot) {
|
|
|
12580
12657
|
staleFiles: results.filter((entry) => entry !== null)
|
|
12581
12658
|
};
|
|
12582
12659
|
}
|
|
12660
|
+
var RESUME_NOTICE_HEADERS = [
|
|
12661
|
+
"[SESSION RESUME FILE VALIDATION]",
|
|
12662
|
+
"[SESSION RESUME INTERRUPTED WORK]"
|
|
12663
|
+
];
|
|
12664
|
+
function isResumeNoticeMessage(message) {
|
|
12665
|
+
if (message.role !== "system" || typeof message.content !== "string") return false;
|
|
12666
|
+
return RESUME_NOTICE_HEADERS.some((header) => message.content === header || message.content.startsWith(`${header}
|
|
12667
|
+
`));
|
|
12668
|
+
}
|
|
12583
12669
|
function formatResumeValidationNotice(validation, projectRoot) {
|
|
12584
12670
|
if (validation.staleFiles.length === 0) return null;
|
|
12585
12671
|
const root = path28.resolve(projectRoot);
|
|
@@ -12776,13 +12862,13 @@ function inheritsIntoFork(event) {
|
|
|
12776
12862
|
|
|
12777
12863
|
// src/storage/session-store/fork-session.ts
|
|
12778
12864
|
async function forkSession(host, id, opts = {}) {
|
|
12779
|
-
const
|
|
12780
|
-
let boundary =
|
|
12865
|
+
const parentEvents = await host.readRawEvents(id);
|
|
12866
|
+
let boundary = parentEvents.length - 1;
|
|
12781
12867
|
let targetCheckpoint;
|
|
12782
12868
|
if (opts.checkpointPromptIndex !== void 0) {
|
|
12783
12869
|
boundary = -1;
|
|
12784
|
-
for (let i = 0; i <
|
|
12785
|
-
const event =
|
|
12870
|
+
for (let i = 0; i < parentEvents.length; i++) {
|
|
12871
|
+
const event = parentEvents[i];
|
|
12786
12872
|
if (event?.type === "checkpoint" && event.promptIndex === opts.checkpointPromptIndex) {
|
|
12787
12873
|
boundary = i;
|
|
12788
12874
|
targetCheckpoint = event;
|
|
@@ -12792,15 +12878,18 @@ async function forkSession(host, id, opts = {}) {
|
|
|
12792
12878
|
throw new Error(`Checkpoint ${opts.checkpointPromptIndex} not found in session "${id}"`);
|
|
12793
12879
|
}
|
|
12794
12880
|
}
|
|
12795
|
-
const parentPrefix =
|
|
12881
|
+
const parentPrefix = parentEvents.slice(0, boundary + 1);
|
|
12796
12882
|
const workspaceCheckpoint = targetCheckpoint?.workspaceCheckpoint;
|
|
12797
12883
|
const checkpointHash = createHash9("sha256").update(parentPrefix.map((event) => JSON.stringify(event)).join("\n") + "\n", "utf8").digest("hex");
|
|
12798
12884
|
const inherited = parentPrefix.filter(inheritsIntoFork);
|
|
12885
|
+
const start = parentEvents.find(
|
|
12886
|
+
(event) => event.type === "session_start"
|
|
12887
|
+
);
|
|
12799
12888
|
const writer = await host.create({
|
|
12800
12889
|
id: "",
|
|
12801
|
-
title:
|
|
12802
|
-
model:
|
|
12803
|
-
provider:
|
|
12890
|
+
title: "",
|
|
12891
|
+
model: start?.model,
|
|
12892
|
+
provider: start?.provider
|
|
12804
12893
|
});
|
|
12805
12894
|
try {
|
|
12806
12895
|
await writer.append({
|
|
@@ -12906,6 +12995,17 @@ var SessionLoadCache = class {
|
|
|
12906
12995
|
this.entries.clear();
|
|
12907
12996
|
this.bytes = 0;
|
|
12908
12997
|
}
|
|
12998
|
+
/**
|
|
12999
|
+
* A hit hands back fresh `messages` / `events` arrays over the cached
|
|
13000
|
+
* contents.
|
|
13001
|
+
*
|
|
13002
|
+
* The entry outlives every caller, and callers treat what they get as their
|
|
13003
|
+
* own: `resume()` passes `messages` straight into a live conversation, and
|
|
13004
|
+
* anything walking `events` may splice it. Returning the cached arrays
|
|
13005
|
+
* themselves let one caller's edit rewrite what the next one loads. The
|
|
13006
|
+
* elements are still shared — copying them would defeat the cache — so
|
|
13007
|
+
* entries remain read-only *contents* behind private containers.
|
|
13008
|
+
*/
|
|
12909
13009
|
getFresh(id, stat15, full) {
|
|
12910
13010
|
const cached = this.entries.get(id);
|
|
12911
13011
|
if (!cached || cached.mtimeMs !== stat15.mtimeMs || cached.size !== stat15.size) {
|
|
@@ -12913,8 +13013,11 @@ var SessionLoadCache = class {
|
|
|
12913
13013
|
}
|
|
12914
13014
|
this.entries.delete(id);
|
|
12915
13015
|
this.entries.set(id, cached);
|
|
12916
|
-
|
|
12917
|
-
|
|
13016
|
+
return {
|
|
13017
|
+
...cached.data,
|
|
13018
|
+
messages: full ? [...cached.data.messages] : [],
|
|
13019
|
+
events: [...cached.data.events]
|
|
13020
|
+
};
|
|
12918
13021
|
}
|
|
12919
13022
|
set(id, stat15, data) {
|
|
12920
13023
|
this.delete(id);
|
|
@@ -13103,6 +13206,9 @@ function stripSnapshotPayload(event) {
|
|
|
13103
13206
|
event.messagesOmitted = event.messages.length;
|
|
13104
13207
|
event.messages = [];
|
|
13105
13208
|
}
|
|
13209
|
+
function isStrippedSnapshot(event) {
|
|
13210
|
+
return event.messages.length === 0 && typeof event.messagesOmitted === "number" && event.messagesOmitted > 0;
|
|
13211
|
+
}
|
|
13106
13212
|
function isSessionEventLike(value) {
|
|
13107
13213
|
return value !== null && typeof value === "object" && typeof value.type === "string" && typeof value.ts === "string";
|
|
13108
13214
|
}
|
|
@@ -13132,7 +13238,12 @@ function replaySessionEvent(params) {
|
|
|
13132
13238
|
emitDamaged(params, `Ignored malformed message_updated event at index ${ev.index}`);
|
|
13133
13239
|
}
|
|
13134
13240
|
} else if (ev.type === "messages_replaced" && ev.version === 1) {
|
|
13135
|
-
if (
|
|
13241
|
+
if (isStrippedSnapshot(ev)) {
|
|
13242
|
+
emitDamaged(
|
|
13243
|
+
params,
|
|
13244
|
+
`Ignored messages_replaced event whose payload was stripped before persistence (${String(ev.messagesOmitted)} messages)`
|
|
13245
|
+
);
|
|
13246
|
+
} else if (applyContextSnapshot(messages, openToolUses, ev.messages)) {
|
|
13136
13247
|
exactJournalActive = true;
|
|
13137
13248
|
} else {
|
|
13138
13249
|
emitDamaged(params, "Ignored malformed messages_replaced event");
|
|
@@ -13148,9 +13259,16 @@ function replaySessionEvent(params) {
|
|
|
13148
13259
|
emitDamaged(params, `Ignored malformed messages_dropped event (count ${String(ev.count)})`);
|
|
13149
13260
|
}
|
|
13150
13261
|
} else if (ev.type === "context_snapshot") {
|
|
13151
|
-
if (
|
|
13262
|
+
if (isStrippedSnapshot(ev)) {
|
|
13263
|
+
emitDamaged(
|
|
13264
|
+
params,
|
|
13265
|
+
`Ignored context_snapshot event whose payload was stripped before persistence (${String(ev.messagesOmitted)} messages)`
|
|
13266
|
+
);
|
|
13267
|
+
} else if (!applyContextSnapshot(messages, openToolUses, ev.messages)) {
|
|
13152
13268
|
emitDamaged(params, "Ignored malformed context_snapshot event");
|
|
13153
13269
|
}
|
|
13270
|
+
} else if (ev.type === "messages_replaced" || ev.type === "message_appended" || ev.type === "message_updated" || ev.type === "messages_dropped") {
|
|
13271
|
+
emitDamaged(params, `Ignored ${ev.type} event with unsupported version`);
|
|
13154
13272
|
} else if (!exactJournalActive && ev.type === "user_input") {
|
|
13155
13273
|
openToolUses.clear();
|
|
13156
13274
|
messages.push({ role: "user", content: ev.content, ts: ev.ts });
|
|
@@ -13203,7 +13321,7 @@ function emitDamaged(params, detail) {
|
|
|
13203
13321
|
import * as fsp15 from "node:fs/promises";
|
|
13204
13322
|
import * as path31 from "node:path";
|
|
13205
13323
|
function isPrunableSessionJsonl(name) {
|
|
13206
|
-
return
|
|
13324
|
+
return isSessionTranscriptFileName(name);
|
|
13207
13325
|
}
|
|
13208
13326
|
async function pruneSessionFiles(storeDir, maxAgeDays, deleteSession) {
|
|
13209
13327
|
const cutoff = Date.now() - maxAgeDays * 864e5;
|
|
@@ -13726,6 +13844,21 @@ var DefaultSessionStore = class _DefaultSessionStore {
|
|
|
13726
13844
|
async fork(id, opts = {}) {
|
|
13727
13845
|
return forkSession(this, id, opts);
|
|
13728
13846
|
}
|
|
13847
|
+
/**
|
|
13848
|
+
* Implements {@link SessionForkHost.readRawEvents} — the parent stream a fork
|
|
13849
|
+
* inherits, unmodified.
|
|
13850
|
+
*
|
|
13851
|
+
* Deliberately NOT `load()`: that loader empties superseded snapshot payloads
|
|
13852
|
+
* in place and front-drops events past its retention budget, both of which
|
|
13853
|
+
* are correct for reconstructing a conversation and wrong for copying a
|
|
13854
|
+
* journal prefix into a child. Streaming with an accept-everything predicate
|
|
13855
|
+
* keeps the scrubbing contract (`searchEvents` scrubs each line the same way
|
|
13856
|
+
* `load()` does) without either transformation.
|
|
13857
|
+
*/
|
|
13858
|
+
async readRawEvents(id) {
|
|
13859
|
+
const hits = await this.searchEvents(id, () => true);
|
|
13860
|
+
return hits.map((hit) => hit.event);
|
|
13861
|
+
}
|
|
13729
13862
|
/**
|
|
13730
13863
|
* Capture the deterministic post-tool workspace identity through the store-owned CAS.
|
|
13731
13864
|
*/
|
|
@@ -13814,14 +13947,15 @@ var DefaultSessionStore = class _DefaultSessionStore {
|
|
|
13814
13947
|
ts: (/* @__PURE__ */ new Date()).toISOString()
|
|
13815
13948
|
});
|
|
13816
13949
|
}
|
|
13950
|
+
const carriedMessages = data.messages.filter((message) => !isResumeNoticeMessage(message));
|
|
13817
13951
|
const resumedData = {
|
|
13818
13952
|
...data,
|
|
13819
13953
|
...resumeValidation ? { resumeValidation } : {},
|
|
13820
|
-
|
|
13954
|
+
messages: [...carriedMessages, ...noticeMessages]
|
|
13821
13955
|
};
|
|
13822
13956
|
let handle;
|
|
13823
13957
|
try {
|
|
13824
|
-
handle = await
|
|
13958
|
+
handle = await openSessionForAppend(file);
|
|
13825
13959
|
} catch (err) {
|
|
13826
13960
|
emitSessionStoreError(this.events, canonicalId, file, "resume", toErrorMessage(err), false);
|
|
13827
13961
|
throw new Error(
|
|
@@ -14000,10 +14134,10 @@ var DefaultSessionStore = class _DefaultSessionStore {
|
|
|
14000
14134
|
const limit = criteria.limit ?? 100;
|
|
14001
14135
|
if (this.catalogClient) {
|
|
14002
14136
|
const records = await this.catalogClient.call("list_catalog", {
|
|
14003
|
-
limit
|
|
14004
|
-
...criteria
|
|
14137
|
+
limit,
|
|
14138
|
+
...criteria
|
|
14005
14139
|
});
|
|
14006
|
-
return this.scrubSummaries(records)
|
|
14140
|
+
return this.scrubSummaries(records);
|
|
14007
14141
|
}
|
|
14008
14142
|
try {
|
|
14009
14143
|
const indexed = await this.readIndex();
|
|
@@ -14228,11 +14362,11 @@ var DefaultSessionStore = class _DefaultSessionStore {
|
|
|
14228
14362
|
return shardKeys;
|
|
14229
14363
|
}
|
|
14230
14364
|
async readOrBuildShardManifest(shardKey) {
|
|
14231
|
-
const cached = this.shardManifestCache.get(shardKey);
|
|
14232
|
-
if (cached) return cached;
|
|
14233
14365
|
const manifestPath = this.shardManifestPath(shardKey);
|
|
14366
|
+
const cached = await this.freshShardManifestCacheEntry(shardKey, manifestPath);
|
|
14367
|
+
if (cached) return cached;
|
|
14234
14368
|
return withFileLock(manifestPath, async () => {
|
|
14235
|
-
const lockedCached = this.
|
|
14369
|
+
const lockedCached = await this.freshShardManifestCacheEntry(shardKey, manifestPath);
|
|
14236
14370
|
if (lockedCached) return lockedCached;
|
|
14237
14371
|
const entry = await readOrBuildShardManifestEntry({
|
|
14238
14372
|
shardKey,
|
|
@@ -14243,10 +14377,38 @@ var DefaultSessionStore = class _DefaultSessionStore {
|
|
|
14243
14377
|
summaryHeaderFor: (ref) => this.summaryHeaderFor(ref),
|
|
14244
14378
|
summaryFor: (id) => this.summaryFor(id)
|
|
14245
14379
|
});
|
|
14246
|
-
|
|
14380
|
+
try {
|
|
14381
|
+
const stat15 = await fsp19.stat(manifestPath);
|
|
14382
|
+
this.shardManifestCache.set(shardKey, {
|
|
14383
|
+
entry,
|
|
14384
|
+
mtimeMs: stat15.mtimeMs,
|
|
14385
|
+
size: stat15.size,
|
|
14386
|
+
ino: stat15.ino
|
|
14387
|
+
});
|
|
14388
|
+
} catch {
|
|
14389
|
+
this.shardManifestCache.delete(shardKey);
|
|
14390
|
+
}
|
|
14247
14391
|
return entry;
|
|
14248
14392
|
});
|
|
14249
14393
|
}
|
|
14394
|
+
/**
|
|
14395
|
+
* Shard manifests are invalidated by other store processes via atomic
|
|
14396
|
+
* delete/rebuild. Validate the in-memory projection against the persisted
|
|
14397
|
+
* file so one long-lived process cannot retain another process's stale view.
|
|
14398
|
+
*/
|
|
14399
|
+
async freshShardManifestCacheEntry(shardKey, manifestPath) {
|
|
14400
|
+
const cached = this.shardManifestCache.get(shardKey);
|
|
14401
|
+
if (!cached) return void 0;
|
|
14402
|
+
try {
|
|
14403
|
+
const stat15 = await fsp19.stat(manifestPath);
|
|
14404
|
+
if (stat15.mtimeMs === cached.mtimeMs && stat15.size === cached.size && stat15.ino === cached.ino) {
|
|
14405
|
+
return cached.entry;
|
|
14406
|
+
}
|
|
14407
|
+
} catch {
|
|
14408
|
+
}
|
|
14409
|
+
this.shardManifestCache.delete(shardKey);
|
|
14410
|
+
return void 0;
|
|
14411
|
+
}
|
|
14250
14412
|
async collectSessionFilesInShard(shardKey) {
|
|
14251
14413
|
const dir = shardKey ? path32.join(this.dir, shardKey) : this.dir;
|
|
14252
14414
|
const entries = await this.collectSessionFiles(dir, shardKey);
|
|
@@ -14441,7 +14603,11 @@ var DefaultSessionStore = class _DefaultSessionStore {
|
|
|
14441
14603
|
const maintenance = this.catalogClient ? await this.catalogClient.call("acquire_maintenance", {
|
|
14442
14604
|
sessionId: canonical,
|
|
14443
14605
|
operation: "clear",
|
|
14444
|
-
holderId: this.maintenanceHolderId
|
|
14606
|
+
holderId: this.maintenanceHolderId,
|
|
14607
|
+
// The catalog store runs inside the detached project daemon; without
|
|
14608
|
+
// our pid it cannot tell "this TUI clearing its own session" from
|
|
14609
|
+
// "another running wstack" and refuses every /clear as `is live`.
|
|
14610
|
+
holderPid: process.pid
|
|
14445
14611
|
}) : void 0;
|
|
14446
14612
|
await this.ensureShardDir(canonical);
|
|
14447
14613
|
const file = this.sessionPath(canonical, ".jsonl");
|
|
@@ -14522,6 +14688,23 @@ var DefaultSessionStore = class _DefaultSessionStore {
|
|
|
14522
14688
|
});
|
|
14523
14689
|
}
|
|
14524
14690
|
};
|
|
14691
|
+
async function openSessionForAppend(file) {
|
|
14692
|
+
const handle = await fsp19.open(file, "a+", 384);
|
|
14693
|
+
try {
|
|
14694
|
+
const stat15 = await handle.stat();
|
|
14695
|
+
if (stat15.size > 0) {
|
|
14696
|
+
const tail = Buffer.allocUnsafe(1);
|
|
14697
|
+
const { bytesRead } = await handle.read(tail, 0, 1, stat15.size - 1);
|
|
14698
|
+
if (bytesRead === 1 && tail[0] !== 10) {
|
|
14699
|
+
await handle.appendFile("\n", "utf8");
|
|
14700
|
+
}
|
|
14701
|
+
}
|
|
14702
|
+
return handle;
|
|
14703
|
+
} catch (err) {
|
|
14704
|
+
await handle.close().catch(() => void 0);
|
|
14705
|
+
throw err;
|
|
14706
|
+
}
|
|
14707
|
+
}
|
|
14525
14708
|
|
|
14526
14709
|
// src/storage/session-rewind-apply.ts
|
|
14527
14710
|
async function applyRewindToConversation(opts) {
|
|
@@ -14634,17 +14817,20 @@ var DefaultSessionRewinder = class {
|
|
|
14634
14817
|
const targetIndex = checkpoints[n - 1]?.promptIndex ?? 0;
|
|
14635
14818
|
const snapshotsToRevert = [];
|
|
14636
14819
|
let shouldRevert = false;
|
|
14820
|
+
let removedEvents = 0;
|
|
14637
14821
|
for await (const event of this.readEvents(file)) {
|
|
14638
14822
|
if (event.type === "checkpoint" && event.promptIndex === targetIndex) {
|
|
14639
14823
|
shouldRevert = true;
|
|
14640
14824
|
continue;
|
|
14641
14825
|
}
|
|
14642
|
-
if (shouldRevert
|
|
14826
|
+
if (!shouldRevert) continue;
|
|
14827
|
+
removedEvents++;
|
|
14828
|
+
if (event.type === "file_snapshot") {
|
|
14643
14829
|
snapshotsToRevert.push({ promptIndex: event.promptIndex, files: event.files });
|
|
14644
14830
|
}
|
|
14645
14831
|
}
|
|
14646
14832
|
const result = await revertSnapshots(snapshotsToRevert, this.projectRoot);
|
|
14647
|
-
return { ...result, toPromptIndex: targetIndex, removedEvents
|
|
14833
|
+
return { ...result, toPromptIndex: targetIndex, removedEvents };
|
|
14648
14834
|
}
|
|
14649
14835
|
async rewindToStart(sessionId) {
|
|
14650
14836
|
const file = this.sessionFile(sessionId);
|
|
@@ -14698,6 +14884,54 @@ async function revertSnapshots(snapshots, projectRoot) {
|
|
|
14698
14884
|
|
|
14699
14885
|
// src/storage/task-store.ts
|
|
14700
14886
|
import * as fsp21 from "node:fs/promises";
|
|
14887
|
+
function assertTaskMutationInvariants(previous, updated) {
|
|
14888
|
+
const ids = updated.tasks.map((task) => task.id);
|
|
14889
|
+
const uniqueIds = new Set(ids);
|
|
14890
|
+
if (uniqueIds.size !== ids.length) {
|
|
14891
|
+
throw new SessionError({
|
|
14892
|
+
message: "Task mutation rejected: task IDs must be unique.",
|
|
14893
|
+
code: "SESSION_WRITE_FAILED",
|
|
14894
|
+
sessionId: updated.sessionId,
|
|
14895
|
+
context: { operation: "mutateTasks", invariant: "unique_task_ids" }
|
|
14896
|
+
});
|
|
14897
|
+
}
|
|
14898
|
+
const omittedUnfinished = previous.tasks.filter(
|
|
14899
|
+
(task) => task.status !== "completed" && !uniqueIds.has(task.id)
|
|
14900
|
+
);
|
|
14901
|
+
if (omittedUnfinished.length > 0) {
|
|
14902
|
+
throw new SessionError({
|
|
14903
|
+
message: `Task mutation rejected: unfinished tasks cannot be omitted: ${omittedUnfinished.map((task) => task.id).join(", ")}. Complete them first.`,
|
|
14904
|
+
code: "SESSION_WRITE_FAILED",
|
|
14905
|
+
sessionId: updated.sessionId,
|
|
14906
|
+
context: { operation: "mutateTasks", invariant: "unfinished_task_coverage" }
|
|
14907
|
+
});
|
|
14908
|
+
}
|
|
14909
|
+
const byId = new Map(updated.tasks.map((task) => [task.id, task]));
|
|
14910
|
+
for (const task of updated.tasks) {
|
|
14911
|
+
const missing = (task.dependsOn ?? []).filter((dependencyId) => !byId.has(dependencyId));
|
|
14912
|
+
if (missing.length > 0) {
|
|
14913
|
+
throw new SessionError({
|
|
14914
|
+
message: `Task mutation rejected: task "${task.id}" references missing dependencies: ${missing.join(", ")}.`,
|
|
14915
|
+
code: "SESSION_WRITE_FAILED",
|
|
14916
|
+
sessionId: updated.sessionId,
|
|
14917
|
+
context: { operation: "mutateTasks", invariant: "dependency_identity" }
|
|
14918
|
+
});
|
|
14919
|
+
}
|
|
14920
|
+
if (task.status === "in_progress" || task.status === "review" || task.status === "completed") {
|
|
14921
|
+
const unmet = (task.dependsOn ?? []).filter(
|
|
14922
|
+
(dependencyId) => byId.get(dependencyId)?.status !== "completed"
|
|
14923
|
+
);
|
|
14924
|
+
if (unmet.length > 0) {
|
|
14925
|
+
throw new SessionError({
|
|
14926
|
+
message: `Task mutation rejected: task "${task.id}" cannot be ${task.status} before dependencies complete: ${unmet.join(", ")}.`,
|
|
14927
|
+
code: "SESSION_WRITE_FAILED",
|
|
14928
|
+
sessionId: updated.sessionId,
|
|
14929
|
+
context: { operation: "mutateTasks", invariant: "dependency_completion" }
|
|
14930
|
+
});
|
|
14931
|
+
}
|
|
14932
|
+
}
|
|
14933
|
+
}
|
|
14934
|
+
}
|
|
14701
14935
|
function emptyTaskFile(sessionId) {
|
|
14702
14936
|
return {
|
|
14703
14937
|
version: 1,
|
|
@@ -14802,7 +15036,9 @@ async function saveTasks(filePath, tasks, events, traceId, warn) {
|
|
|
14802
15036
|
async function mutateTasks(filePath, sessionId, fn, events, traceId) {
|
|
14803
15037
|
return withFileLock(filePath, async () => {
|
|
14804
15038
|
const file = await loadTasks(filePath, events, traceId) ?? emptyTaskFile(sessionId);
|
|
15039
|
+
const previous = structuredClone(file);
|
|
14805
15040
|
const updated = await fn(file);
|
|
15041
|
+
assertTaskMutationInvariants(previous, updated);
|
|
14806
15042
|
const persisted = await saveTasks(filePath, updated, events, traceId);
|
|
14807
15043
|
if (!persisted) {
|
|
14808
15044
|
throw new SessionError({
|