@gajae-code/coding-agent 0.16.4 → 0.16.6
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 +54 -0
- package/dist/types/capability/types.d.ts +4 -0
- package/dist/types/cli/customize-doctor.d.ts +22 -0
- package/dist/types/config/file-lock.d.ts +31 -3
- package/dist/types/coordinator-mcp/question-state.d.ts +7 -0
- package/dist/types/coordinator-mcp/server.d.ts +7 -0
- package/dist/types/discovery/helpers.d.ts +33 -0
- package/dist/types/exec/bash-executor.d.ts +17 -1
- package/dist/types/exec/bash-shell-guardian.d.ts +15 -0
- package/dist/types/exec/bash-shell-runtime-entry.d.ts +2 -0
- package/dist/types/exec/bash-shell-signals.d.ts +2 -0
- package/dist/types/exec/bash-shell-supervisor-entry.d.ts +1 -0
- package/dist/types/exec/bash-shell-supervisor.d.ts +10 -0
- package/dist/types/exec/bash-shell-worker-entry.d.ts +2 -0
- package/dist/types/exec/bash-shell-worker-protocol.d.ts +63 -0
- package/dist/types/exec/bash-shell-worker.d.ts +1 -0
- package/dist/types/exec/isolated-shell.d.ts +19 -0
- package/dist/types/extensibility/extensions/function-hooks-internal.d.ts +9 -0
- package/dist/types/extensibility/extensions/function-hooks.d.ts +169 -0
- package/dist/types/extensibility/extensions/index.d.ts +1 -0
- package/dist/types/extensibility/extensions/runner.d.ts +46 -6
- package/dist/types/extensibility/extensions/types.d.ts +16 -0
- package/dist/types/extensibility/extensions/wrapper.d.ts +3 -1
- package/dist/types/extensibility/gjc-plugins/constrained-hooks.d.ts +35 -2
- package/dist/types/extensibility/gjc-plugins/types.d.ts +25 -0
- package/dist/types/extensibility/hooks/types.d.ts +4 -0
- package/dist/types/extensibility/runtime-skill-discovery.d.ts +4 -1
- package/dist/types/extensibility/skill-management.d.ts +9 -4
- package/dist/types/extensibility/skills.d.ts +2 -0
- package/dist/types/gjc-runtime/empty-delete-gc.d.ts +4 -1
- package/dist/types/gjc-runtime/linux-proc.d.ts +2 -0
- package/dist/types/gjc-runtime/session-state-lock.d.ts +12 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +41 -3
- package/dist/types/gjc-runtime/ultragoal-succession.d.ts +251 -0
- package/dist/types/modes/components/bash-execution.d.ts +1 -0
- package/dist/types/modes/components/execution-shared.d.ts +1 -0
- package/dist/types/modes/interactive-mode.d.ts +2 -0
- package/dist/types/modes/types.d.ts +6 -0
- package/dist/types/sdk/broker/ensure.d.ts +32 -0
- package/dist/types/sdk/broker/lifecycle-ledger.d.ts +0 -1
- package/dist/types/sdk/broker/lifecycle.d.ts +6 -0
- package/dist/types/sdk/broker/startup-failure.d.ts +4 -1
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +2 -0
- package/dist/types/sdk/bus/chat-effect-journal.d.ts +17 -2
- package/dist/types/sdk/bus/conversation-store.d.ts +11 -0
- package/dist/types/sdk/bus/lifecycle-commands.d.ts +8 -8
- package/dist/types/sdk/bus/slack-daemon.d.ts +1 -1
- package/dist/types/sdk/bus/topic-registry.d.ts +8 -0
- package/dist/types/sdk/client/client.d.ts +6 -0
- package/dist/types/sdk/host/host.d.ts +3 -1
- package/dist/types/sdk/host/session-runtime.d.ts +4 -2
- package/dist/types/sdk/lifecycle/service.d.ts +1 -0
- package/dist/types/sdk/router/session-router.d.ts +10 -2
- package/dist/types/sdk/session.d.ts +5 -1
- package/dist/types/session/agent-session.d.ts +2 -0
- package/dist/types/session/fallback-chain-controller.d.ts +15 -0
- package/dist/types/session/internal/managed-session-storage.d.ts +7 -0
- package/dist/types/session/messages.d.ts +1 -0
- package/dist/types/session/session-manager.d.ts +3 -1
- package/dist/types/setup/paseo/announce.d.ts +4 -3
- package/dist/types/skill-state/canonical-skills.d.ts +4 -0
- package/dist/types/system-prompt.d.ts +8 -0
- package/dist/types/task/executor.d.ts +3 -0
- package/dist/types/tools/bash.d.ts +1 -0
- package/dist/types/tools/github-cache.d.ts +2 -1
- package/dist/types/tools/index.d.ts +2 -0
- package/package.json +9 -7
- package/scripts/generate-sdk-operation-inventory.ts +2 -0
- package/src/capability/index.ts +14 -2
- package/src/capability/types.ts +4 -0
- package/src/cli/customize-doctor.ts +155 -12
- package/src/cli.ts +22 -0
- package/src/commands/sdk.ts +74 -31
- package/src/config/file-lock-gc.ts +40 -2
- package/src/config/file-lock.ts +198 -14
- package/src/config/model-preset-registry.ts +115 -16
- package/src/config/model-registry.ts +16 -12
- package/src/coordinator-mcp/question-state.ts +33 -2
- package/src/coordinator-mcp/server.ts +477 -101
- package/src/crash/sanitize.ts +1 -1
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +22 -0
- package/src/discovery/builtin.ts +50 -62
- package/src/discovery/helpers.ts +297 -32
- package/src/exec/bash-executor.ts +330 -50
- package/src/exec/bash-shell-guardian.ts +388 -0
- package/src/exec/bash-shell-runtime-entry.ts +5 -0
- package/src/exec/bash-shell-signals.ts +29 -0
- package/src/exec/bash-shell-supervisor-entry.ts +3 -0
- package/src/exec/bash-shell-supervisor.ts +359 -0
- package/src/exec/bash-shell-worker-entry.ts +5 -0
- package/src/exec/bash-shell-worker-protocol.ts +36 -0
- package/src/exec/bash-shell-worker.ts +116 -0
- package/src/exec/isolated-shell.ts +393 -0
- package/src/extensibility/extensions/function-hooks-internal.ts +73 -0
- package/src/extensibility/extensions/function-hooks.ts +959 -0
- package/src/extensibility/extensions/index.ts +22 -0
- package/src/extensibility/extensions/loader.ts +53 -2
- package/src/extensibility/extensions/runner.ts +895 -264
- package/src/extensibility/extensions/types.ts +42 -0
- package/src/extensibility/extensions/wrapper.ts +56 -15
- package/src/extensibility/gjc-plugins/compiler.ts +20 -2
- package/src/extensibility/gjc-plugins/constrained-hooks.ts +321 -23
- package/src/extensibility/gjc-plugins/registry.ts +113 -1
- package/src/extensibility/gjc-plugins/runtime-quarantine.ts +20 -2
- package/src/extensibility/gjc-plugins/schema.ts +121 -4
- package/src/extensibility/gjc-plugins/types.ts +25 -0
- package/src/extensibility/hooks/loader.ts +133 -16
- package/src/extensibility/hooks/types.ts +28 -0
- package/src/extensibility/runtime-skill-discovery.ts +80 -47
- package/src/extensibility/skill-management.ts +200 -52
- package/src/extensibility/skills.ts +29 -9
- package/src/gjc-runtime/empty-delete-gc.ts +94 -4
- package/src/gjc-runtime/gc-runtime.ts +29 -0
- package/src/gjc-runtime/linux-proc.ts +14 -0
- package/src/gjc-runtime/session-state-lock.ts +51 -8
- package/src/gjc-runtime/session-state-sidecar.ts +27 -11
- package/src/gjc-runtime/state-writer.ts +30 -29
- package/src/gjc-runtime/ultragoal-runtime.ts +189 -47
- package/src/gjc-runtime/ultragoal-succession.ts +1667 -0
- package/src/gjc-runtime/workflow-manifest.generated.json +52 -1
- package/src/gjc-runtime/workflow-manifest.ts +16 -0
- package/src/harness-control-plane/session-lease.ts +2 -2
- package/src/internal-urls/docs-index.generated.ts +6 -6
- package/src/lsp/config.ts +10 -3
- package/src/lsp/index.ts +1 -1
- package/src/lsp/path-trust.ts +69 -14
- package/src/main.ts +6 -1
- package/src/modes/acp/acp-agent.ts +16 -3
- package/src/modes/components/bash-execution.ts +4 -1
- package/src/modes/components/execution-shared.ts +4 -1
- package/src/modes/components/session-observer-overlay.ts +1 -1
- package/src/modes/components/tool-status-header.ts +3 -1
- package/src/modes/controllers/command-controller.ts +1 -0
- package/src/modes/controllers/event-controller.ts +3 -2
- package/src/modes/controllers/extension-ui-controller.ts +1 -0
- package/src/modes/controllers/input-controller.ts +6 -3
- package/src/modes/interactive-mode.ts +6 -5
- package/src/modes/print-mode.ts +5 -1
- package/src/modes/prompt-action-autocomplete.ts +54 -27
- package/src/modes/runtime-init.ts +2 -0
- package/src/modes/types.ts +16 -3
- package/src/modes/utils/ui-helpers.ts +1 -0
- package/src/prompts/tools/subagent.md +1 -0
- package/src/runtime-mcp/manager.ts +20 -2
- package/src/runtime-mcp/pool.test.ts +20 -11
- package/src/sdk/broker/broker.ts +119 -28
- package/src/sdk/broker/ensure.ts +248 -41
- package/src/sdk/broker/lifecycle-ledger.ts +7 -17
- package/src/sdk/broker/lifecycle.ts +56 -6
- package/src/sdk/broker/startup-failure.ts +15 -6
- package/src/sdk/bus/chat-daemon-control.ts +3 -1
- package/src/sdk/bus/chat-daemon-runtime.ts +35 -5
- package/src/sdk/bus/chat-effect-journal.ts +132 -7
- package/src/sdk/bus/conversation-store.ts +29 -0
- package/src/sdk/bus/lifecycle-commands.ts +17 -11
- package/src/sdk/bus/slack-conversation.ts +1 -0
- package/src/sdk/bus/slack-daemon.ts +387 -48
- package/src/sdk/bus/telegram-daemon.ts +16 -2
- package/src/sdk/bus/topic-registry.ts +19 -0
- package/src/sdk/cli/master-cli.ts +1 -1
- package/src/sdk/cli/session-cli.ts +2 -2
- package/src/sdk/client/client.ts +66 -17
- package/src/sdk/host/host.ts +29 -9
- package/src/sdk/host/query/revision-store.ts +19 -13
- package/src/sdk/host/reverse-leases.ts +7 -4
- package/src/sdk/host/session-runtime.test.ts +50 -8
- package/src/sdk/host/session-runtime.ts +158 -51
- package/src/sdk/lifecycle/service.ts +4 -0
- package/src/sdk/protocol/operation-inventory.generated.json +11 -0
- package/src/sdk/router/session-router.ts +89 -38
- package/src/sdk/session.ts +155 -26
- package/src/sdk/transport/relay.ts +35 -13
- package/src/session/agent-session.ts +117 -52
- package/src/session/fallback-chain-controller.ts +38 -0
- package/src/session/internal/managed-session-storage.ts +19 -5
- package/src/session/messages.ts +5 -0
- package/src/session/session-manager.ts +29 -5
- package/src/session-import/redact.ts +5 -1
- package/src/setup/paseo/announce.ts +49 -43
- package/src/setup/paseo/setup-deps.ts +1 -1
- package/src/skill-state/canonical-skills.ts +12 -0
- package/src/skills/skills.test.ts +41 -21
- package/src/system-prompt.ts +25 -3
- package/src/task/executor.ts +5 -0
- package/src/task/index.ts +4 -0
- package/src/tools/bash.ts +28 -11
- package/src/tools/gh.ts +26 -3
- package/src/tools/github-cache.ts +68 -16
- package/src/tools/index.ts +2 -0
- package/src/tools/job.ts +13 -2
- package/src/tools/read.ts +14 -2
- package/src/tools/render-utils.ts +1 -3
- package/src/tools/resource-gc.ts +28 -8
- package/src/tools/skill-discovery.ts +5 -0
- package/src/tools/skill.ts +15 -0
- package/src/tools/subagent.ts +3 -2
- package/src/tools/tool-catalog.generated.ts +2 -2
- package/src/tools/ultragoal-ask-guard.ts +2 -2
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import * as fs from "node:fs/promises";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
getAgentDir,
|
|
4
|
+
getAgentProfileAuthority,
|
|
5
|
+
getProjectDir,
|
|
6
|
+
getTrustedHomeDir,
|
|
7
|
+
normalizePathForComparison,
|
|
8
|
+
} from "@gajae-code/utils";
|
|
3
9
|
import { findRepoRoot } from "../capability/fs";
|
|
4
10
|
import { skillCapability } from "../capability/skill";
|
|
5
11
|
import type { SourceMeta } from "../capability/types";
|
|
@@ -7,9 +13,12 @@ import type { SkillsSettings } from "../config/settings";
|
|
|
7
13
|
import { resolveSkillScopeTrust } from "../config/skill-settings-defaults";
|
|
8
14
|
import { type Skill as CapabilitySkill, getCapability } from "../discovery";
|
|
9
15
|
import { loadMarketplaceSkills } from "../discovery/claude-plugins";
|
|
10
|
-
import { compareSkillOrder, scanSkillsFromDir } from "../discovery/helpers";
|
|
16
|
+
import { compareSkillOrder, resolveUserAgentDir, scanSkillsFromDir } from "../discovery/helpers";
|
|
11
17
|
import type { SkillPromptDetails } from "../session/messages";
|
|
12
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
getSkillFilesystemIdentity,
|
|
20
|
+
isCanonicalGjcWorkflowSkillFilesystemAlias,
|
|
21
|
+
} from "../skill-state/canonical-skills";
|
|
13
22
|
import { expandTilde } from "../tools/path-utils";
|
|
14
23
|
import type { LoadedSubskillActivation } from "./gjc-plugins";
|
|
15
24
|
import { buildSubskillInjection } from "./gjc-plugins/injection";
|
|
@@ -114,6 +123,8 @@ export interface LoadSkillsOptions extends SkillsSettings {
|
|
|
114
123
|
home?: string;
|
|
115
124
|
/** Explicit user agent directory for native user-scope skill discovery. */
|
|
116
125
|
agentDir?: string;
|
|
126
|
+
/** Resolver-owned classification for the selected agent directory. */
|
|
127
|
+
profileAuthority?: "default" | "custom";
|
|
117
128
|
}
|
|
118
129
|
|
|
119
130
|
/**
|
|
@@ -124,8 +135,6 @@ export interface LoadSkillsOptions extends SkillsSettings {
|
|
|
124
135
|
*/
|
|
125
136
|
const LOADABLE_SKILL_PROVIDERS = new Set(["native", "claude-plugins"]);
|
|
126
137
|
|
|
127
|
-
const BUILT_IN_SKILL_NAMES = new Set<string>(CANONICAL_GJC_WORKFLOW_SKILLS.map(name => name.toLowerCase()));
|
|
128
|
-
|
|
129
138
|
/**
|
|
130
139
|
* Load skills from all configured locations.
|
|
131
140
|
* Returns skills and any validation warnings.
|
|
@@ -144,7 +153,17 @@ export async function loadSkills(options: LoadSkillsOptions = {}): Promise<LoadS
|
|
|
144
153
|
if (!enabled) {
|
|
145
154
|
return { skills: [], warnings: [] };
|
|
146
155
|
}
|
|
156
|
+
const homeWasInjected = options.home !== undefined;
|
|
147
157
|
const home = options.home ?? getTrustedHomeDir();
|
|
158
|
+
const agentDir = options.agentDir ?? (homeWasInjected ? resolveUserAgentDir(home) : getAgentDir());
|
|
159
|
+
const profileAuthority =
|
|
160
|
+
options.profileAuthority ??
|
|
161
|
+
(!homeWasInjected
|
|
162
|
+
? options.agentDir !== undefined &&
|
|
163
|
+
normalizePathForComparison(options.agentDir) !== normalizePathForComparison(getAgentDir())
|
|
164
|
+
? "custom"
|
|
165
|
+
: getAgentProfileAuthority()
|
|
166
|
+
: undefined);
|
|
148
167
|
|
|
149
168
|
const projectTrusted = resolveSkillScopeTrust(options, "project");
|
|
150
169
|
const userTrusted = resolveSkillScopeTrust(options, "user");
|
|
@@ -175,7 +194,8 @@ export async function loadSkills(options: LoadSkillsOptions = {}): Promise<LoadS
|
|
|
175
194
|
const loadContext = {
|
|
176
195
|
cwd,
|
|
177
196
|
home,
|
|
178
|
-
userAgentDir:
|
|
197
|
+
userAgentDir: agentDir,
|
|
198
|
+
profileAuthority,
|
|
179
199
|
repoRoot: await findRepoRoot(cwd),
|
|
180
200
|
};
|
|
181
201
|
const providerResults = await Promise.all(
|
|
@@ -258,14 +278,14 @@ export async function loadSkills(options: LoadSkillsOptions = {}): Promise<LoadS
|
|
|
258
278
|
// workflow-routing hijack: warn, and let the session merge keep the bundled
|
|
259
279
|
// definition authoritative (sdk/session.ts). User-scope copies are not
|
|
260
280
|
// warned about: installed defaults legitimately mirror bundled skills.
|
|
261
|
-
if (capSkill.level === "project" &&
|
|
281
|
+
if (capSkill.level === "project" && isCanonicalGjcWorkflowSkillFilesystemAlias(capSkill.name)) {
|
|
262
282
|
collisionWarnings.push({
|
|
263
283
|
skillPath: capSkill.path,
|
|
264
284
|
message: `name collision: "${capSkill.name}" is a bundled GJC workflow skill; the bundled definition takes precedence in sessions and this project copy is never used`,
|
|
265
285
|
});
|
|
266
286
|
}
|
|
267
287
|
|
|
268
|
-
const skillKey = capSkill.name
|
|
288
|
+
const skillKey = getSkillFilesystemIdentity(capSkill.name);
|
|
269
289
|
const existing = skillMap.get(skillKey);
|
|
270
290
|
if (existing) {
|
|
271
291
|
collisionWarnings.push({
|
|
@@ -342,7 +362,7 @@ export async function loadSkills(options: LoadSkillsOptions = {}): Promise<LoadS
|
|
|
342
362
|
const resolvedPath = customRealPaths[i];
|
|
343
363
|
if (realPathSet.has(resolvedPath)) continue;
|
|
344
364
|
|
|
345
|
-
const skillKey = skill.name
|
|
365
|
+
const skillKey = getSkillFilesystemIdentity(skill.name);
|
|
346
366
|
const existing = skillMap.get(skillKey);
|
|
347
367
|
if (existing) {
|
|
348
368
|
collisionWarnings.push({
|
|
@@ -7,11 +7,15 @@ import { randomUUID } from "node:crypto";
|
|
|
7
7
|
import type { BigIntStats } from "node:fs";
|
|
8
8
|
import * as fs from "node:fs/promises";
|
|
9
9
|
import * as path from "node:path";
|
|
10
|
-
import { exactUnlinkDirect, type NativeExactUnlinkResult } from "@gajae-code/natives";
|
|
10
|
+
import { exactUnlinkDirect, type NativeExactUnlinkResult, snapshotDirectoryTree } from "@gajae-code/natives";
|
|
11
11
|
|
|
12
12
|
export const EMPTY_DELETE_PREFIX = ".gjc-delete-";
|
|
13
13
|
const EMPTY_DELETE_RECEIPT_PATTERN =
|
|
14
14
|
/^\.gjc-delete-session-state-lock-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.json$/u;
|
|
15
|
+
// session-storage uses a GC UUID; managed-session-scope uses stableOperationName's
|
|
16
|
+
// SHA-256 plus a numbered attempt. Neither arbitrary labels nor other receipts qualify.
|
|
17
|
+
const EMPTY_ARTIFACT_REMOVAL_ROOT_PATTERN =
|
|
18
|
+
/^\.gjc-delete-(?:gc-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}-artifacts|[0-9a-f]{64}-artifacts-[1-9]\d*)\.removing$/u;
|
|
15
19
|
const EMPTY_FILE_SHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
|
|
16
20
|
|
|
17
21
|
export interface EmptyDeleteIdentity {
|
|
@@ -22,7 +26,7 @@ export interface EmptyDeleteIdentity {
|
|
|
22
26
|
mtimeNs: bigint;
|
|
23
27
|
parentDev: bigint;
|
|
24
28
|
parentIno: bigint;
|
|
25
|
-
sha256
|
|
29
|
+
sha256?: string;
|
|
26
30
|
}
|
|
27
31
|
|
|
28
32
|
export interface EmptyDeleteGcRecord {
|
|
@@ -51,12 +55,15 @@ export interface EmptyDeleteGcReport {
|
|
|
51
55
|
export interface EmptyDeleteGcOptions {
|
|
52
56
|
roots: string[];
|
|
53
57
|
prune: boolean;
|
|
58
|
+
/** Disk artifact GC must not remove unrelated file receipts. */
|
|
59
|
+
artifactRootsOnly?: boolean;
|
|
54
60
|
}
|
|
55
61
|
|
|
56
62
|
export interface EmptyDeleteGcDependencies {
|
|
57
63
|
/** Internal seam for deterministic collect-to-prune race tests. */
|
|
58
64
|
collect?: (root: string) => Promise<EmptyDeleteGcRecord[]>;
|
|
59
65
|
exactUnlinkDirect?: typeof exactUnlinkDirect;
|
|
66
|
+
rmdir?: (directory: string) => Promise<void>;
|
|
60
67
|
}
|
|
61
68
|
|
|
62
69
|
function isUnsafeName(name: string): boolean {
|
|
@@ -100,6 +107,64 @@ function isEmptyDeleteReceiptName(name: string): boolean {
|
|
|
100
107
|
return EMPTY_DELETE_RECEIPT_PATTERN.test(name);
|
|
101
108
|
}
|
|
102
109
|
|
|
110
|
+
function isEmptyArtifactRemovalRootName(name: string): boolean {
|
|
111
|
+
return EMPTY_ARTIFACT_REMOVAL_ROOT_PATTERN.test(name);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function artifactRootReason(file: string, stat: BigIntStats): string {
|
|
115
|
+
if (!stat.isDirectory()) return "not_directory";
|
|
116
|
+
// The native snapshot opens every component with O_NOFOLLOW and enumerates
|
|
117
|
+
// with fdopendir/readdir on the retained directory handle. A root-only snapshot
|
|
118
|
+
// proves zero entries; pathname readdir alone cannot supply that authority.
|
|
119
|
+
const result = snapshotDirectoryTree(file);
|
|
120
|
+
if (!result.ok || !result.snapshot) return `entry_unverifiable: ${result.code ?? "unknown"}`;
|
|
121
|
+
const snapshot = result.snapshot;
|
|
122
|
+
if (snapshot.rootDev !== stat.dev.toString() || snapshot.rootIno !== stat.ino.toString()) return "identity_drift";
|
|
123
|
+
return snapshot.entries.length === 1 && snapshot.entries[0].relativePath === ""
|
|
124
|
+
? "empty_artifact_removal_root"
|
|
125
|
+
: "non_empty";
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function removeEmptyArtifactRoot(
|
|
129
|
+
record: EmptyDeleteGcRecord,
|
|
130
|
+
rmdir: (directory: string) => Promise<void>,
|
|
131
|
+
): Promise<void> {
|
|
132
|
+
try {
|
|
133
|
+
const expected = record.identity;
|
|
134
|
+
if (!expected) {
|
|
135
|
+
record.action = "kept";
|
|
136
|
+
record.reason = "identity_missing";
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const parent = await fs.lstat(path.dirname(record.path), { bigint: true });
|
|
140
|
+
const stat = await fs.lstat(record.path, { bigint: true });
|
|
141
|
+
if (
|
|
142
|
+
!parent.isDirectory() ||
|
|
143
|
+
parent.dev !== expected.parentDev ||
|
|
144
|
+
parent.ino !== expected.parentIno ||
|
|
145
|
+
!stat.isDirectory() ||
|
|
146
|
+
stat.dev !== expected.dev ||
|
|
147
|
+
stat.ino !== expected.ino
|
|
148
|
+
) {
|
|
149
|
+
record.action = "kept";
|
|
150
|
+
record.reason = "identity_drift";
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const reason = artifactRootReason(record.path, stat);
|
|
154
|
+
if (reason !== "empty_artifact_removal_root") {
|
|
155
|
+
record.action = "kept";
|
|
156
|
+
record.reason = reason;
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
// Never recursive: a writer winning after the empty check makes this fail closed.
|
|
160
|
+
await rmdir(record.path);
|
|
161
|
+
record.action = "removed";
|
|
162
|
+
} catch (error) {
|
|
163
|
+
record.action = "kept";
|
|
164
|
+
record.reason = `entry_remove_failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
103
168
|
function sameRootIdentity(left: BigIntStats, right: BigIntStats): boolean {
|
|
104
169
|
return left.isDirectory() && right.isDirectory() && left.dev === right.dev && left.ino === right.ino;
|
|
105
170
|
}
|
|
@@ -150,7 +215,7 @@ export async function collectEmptyDeleteReceipts(
|
|
|
150
215
|
throw error;
|
|
151
216
|
}
|
|
152
217
|
for (const name of names) {
|
|
153
|
-
if (isUnsafeName(name) || !isEmptyDeleteReceiptName(name)) continue;
|
|
218
|
+
if (isUnsafeName(name) || (!isEmptyDeleteReceiptName(name) && !isEmptyArtifactRemovalRootName(name))) continue;
|
|
154
219
|
const file = path.join(resolvedRoot, name);
|
|
155
220
|
let stat: BigIntStats;
|
|
156
221
|
try {
|
|
@@ -169,6 +234,17 @@ export async function collectEmptyDeleteReceipts(
|
|
|
169
234
|
records.push({ root, path: file, action: "kept", reason: "symlink" });
|
|
170
235
|
continue;
|
|
171
236
|
}
|
|
237
|
+
if (isEmptyArtifactRemovalRootName(name)) {
|
|
238
|
+
const reason = artifactRootReason(file, stat);
|
|
239
|
+
records.push({
|
|
240
|
+
root,
|
|
241
|
+
path: file,
|
|
242
|
+
action: reason === "empty_artifact_removal_root" ? "would_remove" : "kept",
|
|
243
|
+
reason,
|
|
244
|
+
identity: { ...identityOf(stat), parentDev: rootStat.dev, parentIno: rootStat.ino },
|
|
245
|
+
});
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
172
248
|
if (!stat.isFile()) {
|
|
173
249
|
records.push({ root, path: file, action: "kept", reason: "not_regular" });
|
|
174
250
|
continue;
|
|
@@ -232,6 +308,12 @@ export async function runEmptyDeleteGc(
|
|
|
232
308
|
continue;
|
|
233
309
|
}
|
|
234
310
|
for (const record of records) {
|
|
311
|
+
if (
|
|
312
|
+
options.artifactRootsOnly &&
|
|
313
|
+
record.path !== root &&
|
|
314
|
+
!isEmptyArtifactRemovalRootName(path.basename(record.path))
|
|
315
|
+
)
|
|
316
|
+
continue;
|
|
235
317
|
if (record.action === "would_remove" && !options.prune) record.observationOnly = true;
|
|
236
318
|
if (
|
|
237
319
|
record.action === "skipped" &&
|
|
@@ -244,7 +326,15 @@ export async function runEmptyDeleteGc(
|
|
|
244
326
|
) {
|
|
245
327
|
report.errors.push(`${record.path}: ${record.reason}`);
|
|
246
328
|
}
|
|
247
|
-
if (
|
|
329
|
+
if (
|
|
330
|
+
record.action === "would_remove" &&
|
|
331
|
+
options.prune &&
|
|
332
|
+
isEmptyArtifactRemovalRootName(path.basename(record.path))
|
|
333
|
+
) {
|
|
334
|
+
await removeEmptyArtifactRoot(record, deps.rmdir ?? fs.rmdir);
|
|
335
|
+
if (record.reason.startsWith("entry_remove_failed:"))
|
|
336
|
+
report.errors.push(`${record.path}: ${record.reason}`);
|
|
337
|
+
} else if (record.action === "would_remove" && options.prune) {
|
|
248
338
|
try {
|
|
249
339
|
if (!record.identity) {
|
|
250
340
|
record.action = "kept";
|
|
@@ -1704,6 +1704,35 @@ async function runGcDiskArtifacts(input: {
|
|
|
1704
1704
|
const sessionIndex = new SessionIndex(agentDir);
|
|
1705
1705
|
try {
|
|
1706
1706
|
await sessionIndex.withLocked(async () => {
|
|
1707
|
+
// Quarantine roots can outlive every transcript, so enumerate project
|
|
1708
|
+
// directories independently of survivors, under the same index lock.
|
|
1709
|
+
let projects: Dirent[] = [];
|
|
1710
|
+
try {
|
|
1711
|
+
projects = await fsp.readdir(surface.root, { withFileTypes: true });
|
|
1712
|
+
} catch (error) {
|
|
1713
|
+
if (!isEnoent(error))
|
|
1714
|
+
errors.push({ surface: "artifacts", scope: surface.root, message: gcDiskErrorText(error) });
|
|
1715
|
+
}
|
|
1716
|
+
for (const project of projects) {
|
|
1717
|
+
if (!project.isDirectory() || project.isSymbolicLink()) continue;
|
|
1718
|
+
const directory = path.join(surface.root, project.name);
|
|
1719
|
+
const cleanup = await runEmptyDeleteGc({ roots: [directory], prune, artifactRootsOnly: true });
|
|
1720
|
+
for (const message of cleanup.errors) errors.push({ surface: "artifacts", scope: directory, message });
|
|
1721
|
+
for (const receipt of cleanup.records) {
|
|
1722
|
+
const failed = receipt.reason.startsWith("entry_remove_failed:");
|
|
1723
|
+
surface.records.push({
|
|
1724
|
+
surface: "artifacts",
|
|
1725
|
+
id: `${project.name}/${path.basename(receipt.path)}`,
|
|
1726
|
+
path: receipt.path,
|
|
1727
|
+
bytes: 0,
|
|
1728
|
+
age_days: receipt.identity ? gcDiskAgeDays(now, Number(receipt.identity.mtimeNs / 1_000_000n)) : 0,
|
|
1729
|
+
action: receipt.action === "removed" ? "reclaimed" : failed ? "reclaim_failed" : "keep",
|
|
1730
|
+
reason: receipt.reason,
|
|
1731
|
+
...(receipt.action === "kept" || receipt.action === "skipped" ? { withheld: true as const } : {}),
|
|
1732
|
+
...(failed ? { error: receipt.reason } : {}),
|
|
1733
|
+
});
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1707
1736
|
// Recomputed over survivors: whichever transcript the sessions surface left
|
|
1708
1737
|
// newest in a project directory is the `--continue` target, and a resumed
|
|
1709
1738
|
// session still reads its own `artifact://` handles.
|
|
@@ -66,6 +66,20 @@ export function parseLinuxProcState(stat: string | null | undefined): string | n
|
|
|
66
66
|
return parseLinuxProcIdentity(stat)?.state ?? null;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
/** Parse field 4 (`ppid`) from a `/proc/<pid>/stat` record. */
|
|
70
|
+
export function parseLinuxProcParentPid(stat: string | null | undefined): number | null {
|
|
71
|
+
if (!stat || stat.includes("\0") || stat.includes("\r")) return null;
|
|
72
|
+
const record = stat.endsWith("\n") ? stat.slice(0, -1) : stat;
|
|
73
|
+
const close = record.lastIndexOf(")");
|
|
74
|
+
if (close < 0) return null;
|
|
75
|
+
const fields = record
|
|
76
|
+
.slice(close + 1)
|
|
77
|
+
.trim()
|
|
78
|
+
.split(/[ \t]+/);
|
|
79
|
+
const parentPid = Number.parseInt(fields[1] ?? "", 10);
|
|
80
|
+
return Number.isSafeInteger(parentPid) && parentPid >= 0 ? parentPid : null;
|
|
81
|
+
}
|
|
82
|
+
|
|
69
83
|
export function probeLinuxProcPidSync(pid: number): LinuxProcPidProbeResult {
|
|
70
84
|
if (!Number.isSafeInteger(pid) || pid <= 0) return { kind: "unverifiable", reason: "invalid_pid" };
|
|
71
85
|
if (process.platform !== "linux") return { kind: "unverifiable", reason: "unsupported_platform" };
|
|
@@ -37,18 +37,20 @@ const EMPTY_FILE_SHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca4959
|
|
|
37
37
|
*/
|
|
38
38
|
const LOCK_ACQUIRE_RETRY_MS = 5;
|
|
39
39
|
const LOCK_ACQUIRE_MAX_RETRY_MS = 100;
|
|
40
|
-
const LOCK_ACQUIRE_TIMEOUT_MS =
|
|
40
|
+
const LOCK_ACQUIRE_TIMEOUT_MS = 5_000;
|
|
41
41
|
const LOCK_STALE_MS = 30_000;
|
|
42
42
|
const RELEASED_TRANSITION_GRACE_MS = 1_000;
|
|
43
|
+
const SESSION_STATE_LOCK_BUSY = Symbol("session-state-lock-busy");
|
|
43
44
|
|
|
44
45
|
interface LockRetryBudget {
|
|
45
46
|
startedAt: number;
|
|
46
47
|
nextDelayMs: number;
|
|
47
48
|
attempts: number;
|
|
49
|
+
nonBlocking: boolean;
|
|
48
50
|
}
|
|
49
51
|
|
|
50
|
-
function lockRetryBudget(): LockRetryBudget {
|
|
51
|
-
return { startedAt: performance.now(), nextDelayMs: LOCK_ACQUIRE_RETRY_MS, attempts: 0 };
|
|
52
|
+
function lockRetryBudget(nonBlocking = false): LockRetryBudget {
|
|
53
|
+
return { startedAt: performance.now(), nextDelayMs: LOCK_ACQUIRE_RETRY_MS, attempts: 0, nonBlocking };
|
|
52
54
|
}
|
|
53
55
|
|
|
54
56
|
function lockRetryElapsedMs(budget: LockRetryBudget): number {
|
|
@@ -160,6 +162,7 @@ async function joinLocalTransitionQueue(
|
|
|
160
162
|
): Promise<LocalTransitionQueue | undefined> {
|
|
161
163
|
const queue = localTransitionQueues.get(key);
|
|
162
164
|
if (!queue) return undefined;
|
|
165
|
+
if (retryBudget?.nonBlocking) throw SESSION_STATE_LOCK_BUSY;
|
|
163
166
|
const ready = Promise.withResolvers<void>();
|
|
164
167
|
const queuedAt = performance.now();
|
|
165
168
|
SessionStateLockTestHooks.afterLocalTransitionQueued?.(transitionDir);
|
|
@@ -1819,6 +1822,7 @@ async function recoverPendingTransitionRelease(
|
|
|
1819
1822
|
transitionDir: string,
|
|
1820
1823
|
recoveryKey: string,
|
|
1821
1824
|
quarantineName: string,
|
|
1825
|
+
nonBlocking = false,
|
|
1822
1826
|
): Promise<boolean> {
|
|
1823
1827
|
let pendingKey = recoveryKey;
|
|
1824
1828
|
let pending = pendingTransitionReleases.get(pendingKey);
|
|
@@ -1851,7 +1855,10 @@ async function recoverPendingTransitionRelease(
|
|
|
1851
1855
|
// for a contender while that release is still in progress. The owner that armed
|
|
1852
1856
|
// it flips this bit only when a phase actually throws.
|
|
1853
1857
|
if (!pending.recoverable) return false;
|
|
1854
|
-
if (pending.recovery)
|
|
1858
|
+
if (pending.recovery) {
|
|
1859
|
+
if (nonBlocking) throw SESSION_STATE_LOCK_BUSY;
|
|
1860
|
+
return await pending.recovery;
|
|
1861
|
+
}
|
|
1855
1862
|
const recovery = (async (): Promise<boolean> => {
|
|
1856
1863
|
const ownerFile = `${transitionDir}.owner`;
|
|
1857
1864
|
if (!pending.generation) {
|
|
@@ -2097,7 +2104,7 @@ async function runLockPathTransition<T>(
|
|
|
2097
2104
|
for (;;) {
|
|
2098
2105
|
if (ownerAccessStrategy() === "unsupported" && fsSync.existsSync(transitionDir))
|
|
2099
2106
|
throw new SessionStateLockUnavailableError(new Error("Safe transition ownership is unsupported."));
|
|
2100
|
-
if (await recoverPendingTransitionRelease(transitionDir, recoveryKey, quarantineName)) {
|
|
2107
|
+
if (await recoverPendingTransitionRelease(transitionDir, recoveryKey, quarantineName, budget.nonBlocking)) {
|
|
2101
2108
|
// The claim this process stranded in an earlier failed release is gone;
|
|
2102
2109
|
// fall through and retry the mkdir immediately.
|
|
2103
2110
|
}
|
|
@@ -2108,10 +2115,15 @@ async function runLockPathTransition<T>(
|
|
|
2108
2115
|
await fs.chmod(transitionDir, 0o700);
|
|
2109
2116
|
} catch (error) {
|
|
2110
2117
|
const code = (error as NodeJS.ErrnoException).code;
|
|
2111
|
-
if (code !== "EEXIST" && !isTransientLockError(error))
|
|
2118
|
+
if (code !== "EEXIST" && (budget.nonBlocking || !isTransientLockError(error)))
|
|
2119
|
+
throw new SessionStateLockUnavailableError(error);
|
|
2112
2120
|
if (await joinLocalTurn()) continue;
|
|
2113
2121
|
await SessionStateLockTestHooks.afterTransitionClaimContention?.(transitionDir);
|
|
2114
|
-
|
|
2122
|
+
const reclaimed = await reclaimStaleTransitionClaim(transitionDir, quarantineName);
|
|
2123
|
+
// One safe reclaim may make progress, but a try-acquire never follows a
|
|
2124
|
+
// succession of contenders. A later maintenance pass can take the freed claim.
|
|
2125
|
+
if (budget.nonBlocking) throw SESSION_STATE_LOCK_BUSY;
|
|
2126
|
+
if (reclaimed) {
|
|
2115
2127
|
if (lockRetryExhausted(budget))
|
|
2116
2128
|
throw lockUnavailable(transitionDir, "transition_claim_timeout", budget, error);
|
|
2117
2129
|
continue;
|
|
@@ -2551,8 +2563,33 @@ export async function reclaimStaleSessionStateLock(lockFile: string, quarantineN
|
|
|
2551
2563
|
* lock file itself and may run for as long as it needs.
|
|
2552
2564
|
*/
|
|
2553
2565
|
export async function withSessionStateFileLock<T>(stateFile: string, operation: () => Promise<T>): Promise<T> {
|
|
2566
|
+
return await runWithSessionStateFileLock(stateFile, operation, lockRetryBudget());
|
|
2567
|
+
}
|
|
2568
|
+
|
|
2569
|
+
/**
|
|
2570
|
+
* Try one admission without joining a live owner's queue or backing off on contention.
|
|
2571
|
+
* A stale reclaim may return `acquired: false` after making the next attempt possible.
|
|
2572
|
+
* Once admitted, the operation and identity-safe release use the normal lock protocol;
|
|
2573
|
+
* their failures propagate rather than being reported as contention.
|
|
2574
|
+
*/
|
|
2575
|
+
export async function tryWithSessionStateFileLock<T>(
|
|
2576
|
+
stateFile: string,
|
|
2577
|
+
operation: () => Promise<T>,
|
|
2578
|
+
): Promise<{ acquired: true; value: T } | { acquired: false }> {
|
|
2579
|
+
try {
|
|
2580
|
+
return { acquired: true, value: await runWithSessionStateFileLock(stateFile, operation, lockRetryBudget(true)) };
|
|
2581
|
+
} catch (error) {
|
|
2582
|
+
if (error === SESSION_STATE_LOCK_BUSY) return { acquired: false };
|
|
2583
|
+
throw error;
|
|
2584
|
+
}
|
|
2585
|
+
}
|
|
2586
|
+
|
|
2587
|
+
async function runWithSessionStateFileLock<T>(
|
|
2588
|
+
stateFile: string,
|
|
2589
|
+
operation: () => Promise<T>,
|
|
2590
|
+
budget: LockRetryBudget,
|
|
2591
|
+
): Promise<T> {
|
|
2554
2592
|
const lockFile = `${stateFile}.lock`;
|
|
2555
|
-
const budget = lockRetryBudget();
|
|
2556
2593
|
let owner: SessionStateLockOwner;
|
|
2557
2594
|
try {
|
|
2558
2595
|
owner = await newLockOwner();
|
|
@@ -2608,6 +2645,11 @@ export async function withSessionStateFileLock<T>(stateFile: string, operation:
|
|
|
2608
2645
|
if (callbackFailure && error === callbackError) throw error;
|
|
2609
2646
|
// A fault after the lock was taken belongs to the operation, not to acquisition.
|
|
2610
2647
|
if (held) throw lockDiagnostic(error, lockFile, "lock_release_failed");
|
|
2648
|
+
if (error === SESSION_STATE_LOCK_BUSY) throw error;
|
|
2649
|
+
// Typed protocol failures include transition setup/release faults. A
|
|
2650
|
+
// try-acquire must not downgrade those into a successful busy response.
|
|
2651
|
+
if (budget.nonBlocking && error instanceof SessionStateLockUnavailableError)
|
|
2652
|
+
throw lockDiagnostic(error, lockFile, "lock_inspection_failed", budget);
|
|
2611
2653
|
// Without a safe owner-record access strategy, retrying cannot make the
|
|
2612
2654
|
// transition claim removable. Preserve it as a fail-closed fence instead
|
|
2613
2655
|
// of spinning until the acquisition budget expires.
|
|
@@ -2630,6 +2672,7 @@ export async function withSessionStateFileLock<T>(stateFile: string, operation:
|
|
|
2630
2672
|
);
|
|
2631
2673
|
if (reclaim === "owner_unprovenanced")
|
|
2632
2674
|
throw lockUnavailable(lockFile, "lock_owner_record_unprovenanced", budget);
|
|
2675
|
+
if (budget.nonBlocking) throw SESSION_STATE_LOCK_BUSY;
|
|
2633
2676
|
if (reclaim === "legacy_directory_unprovenanced") lastReason = "legacy_directory_owner_unprovenanced";
|
|
2634
2677
|
else if (reclaim === "owner_live_or_unverifiable") lastReason = "lock_owner_live_or_unverifiable";
|
|
2635
2678
|
else if (reclaim === "owner_record_fresh") lastReason = "lock_owner_record_fresh";
|
|
@@ -1004,9 +1004,13 @@ export function eventAffectsCoordinatorRuntimeState(event: RuntimeStateEvent): b
|
|
|
1004
1004
|
}
|
|
1005
1005
|
|
|
1006
1006
|
class PreviousRuntimeStateReadError extends Error {
|
|
1007
|
-
constructor(cause?: unknown) {
|
|
1007
|
+
constructor(cause?: unknown, detail?: string) {
|
|
1008
1008
|
const lockDetail = cause instanceof SessionStateLockUnavailableError ? ` ${cause.message}` : "";
|
|
1009
|
-
super(
|
|
1009
|
+
super(
|
|
1010
|
+
detail
|
|
1011
|
+
? `Existing runtime state marker violates the lifecycle contract: ${detail}; refusing to overwrite.`
|
|
1012
|
+
: `Existing runtime state marker is invalid or unreadable; refusing to overwrite.${lockDetail}`,
|
|
1013
|
+
);
|
|
1010
1014
|
this.name = "PreviousRuntimeStateReadError";
|
|
1011
1015
|
if (cause !== undefined) this.cause = cause;
|
|
1012
1016
|
}
|
|
@@ -1016,7 +1020,7 @@ class PreviousRuntimeStateReadError extends Error {
|
|
|
1016
1020
|
* A readable, well-formed marker recorded against a different workspace path.
|
|
1017
1021
|
*
|
|
1018
1022
|
* Separate from {@link PreviousRuntimeStateReadError} because the two demand different
|
|
1019
|
-
* operator responses: an
|
|
1023
|
+
* operator responses: an invalid marker points at file damage or lifecycle contradictions, while a foreign marker
|
|
1020
1024
|
* points at the same session directory being reachable from two checkouts.
|
|
1021
1025
|
*/
|
|
1022
1026
|
class ForeignRuntimeStateError extends Error {
|
|
@@ -1084,11 +1088,29 @@ function isAbsentStateFileError(error: unknown): boolean {
|
|
|
1084
1088
|
|
|
1085
1089
|
function parsePreviousPayload(raw: string): Record<string, unknown> {
|
|
1086
1090
|
const payload: unknown = JSON.parse(raw);
|
|
1087
|
-
if (!
|
|
1091
|
+
if (!validPreviousRuntimeStateShape(payload)) throw new PreviousRuntimeStateReadError();
|
|
1092
|
+
// Structural validation above bounds every interpolated value to a known state
|
|
1093
|
+
// or boolean; never include arbitrary marker contents in a terminal diagnostic.
|
|
1094
|
+
if (payload.ready_for_input !== undefined) {
|
|
1095
|
+
const expectedReady = payload.state === "ready_for_input";
|
|
1096
|
+
if (payload.ready_for_input !== expectedReady)
|
|
1097
|
+
throw new PreviousRuntimeStateReadError(
|
|
1098
|
+
undefined,
|
|
1099
|
+
`ready_for_input must be ${expectedReady} when state is ${payload.state} (received ${payload.ready_for_input})`,
|
|
1100
|
+
);
|
|
1101
|
+
}
|
|
1102
|
+
if (payload.live !== undefined && payload.live !== null) {
|
|
1103
|
+
const expectedLive = payload.state === "running";
|
|
1104
|
+
if (payload.live !== expectedLive)
|
|
1105
|
+
throw new PreviousRuntimeStateReadError(
|
|
1106
|
+
undefined,
|
|
1107
|
+
`live must be ${expectedLive} when state is ${payload.state} (received ${payload.live})`,
|
|
1108
|
+
);
|
|
1109
|
+
}
|
|
1088
1110
|
return payload;
|
|
1089
1111
|
}
|
|
1090
1112
|
|
|
1091
|
-
function
|
|
1113
|
+
function validPreviousRuntimeStateShape(value: unknown): value is Record<string, unknown> {
|
|
1092
1114
|
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
1093
1115
|
const payload = value as Record<string, unknown>;
|
|
1094
1116
|
if (
|
|
@@ -1125,12 +1147,6 @@ function validPreviousRuntimeStatePayload(value: unknown): value is Record<strin
|
|
|
1125
1147
|
(typeof payload.updated_at !== "string" || !Number.isFinite(Date.parse(payload.updated_at)))
|
|
1126
1148
|
)
|
|
1127
1149
|
return false;
|
|
1128
|
-
if (payload.ready_for_input !== undefined) {
|
|
1129
|
-
const expectedReady = payload.state === "ready_for_input";
|
|
1130
|
-
if (payload.ready_for_input !== expectedReady) return false;
|
|
1131
|
-
}
|
|
1132
|
-
if (payload.live !== undefined && payload.live !== null && payload.live !== (payload.state === "running"))
|
|
1133
|
-
return false;
|
|
1134
1150
|
return true;
|
|
1135
1151
|
}
|
|
1136
1152
|
|
|
@@ -533,40 +533,41 @@ async function writeGuardedResolvedJsonAtomic(
|
|
|
533
533
|
value: unknown,
|
|
534
534
|
options: GuardedStateWriterOptions,
|
|
535
535
|
): Promise<GuardedWriteResult> {
|
|
536
|
-
|
|
537
|
-
filePath
|
|
538
|
-
|
|
539
|
-
const current = await readJsonIfPresentTolerant(filePath);
|
|
540
|
-
const currentRevision = persistedStateRevision(current);
|
|
541
|
-
|
|
542
|
-
if (options.policy === "source") {
|
|
543
|
-
if (options.expectedRevision !== undefined && options.expectedRevision !== currentRevision) {
|
|
544
|
-
throw new StateWriteConflictError(filePath, options.expectedRevision, currentRevision);
|
|
545
|
-
}
|
|
546
|
-
const next = stampStateRevision(withWorkflowReceipt(value, buildReceipt(options)), currentRevision + 1);
|
|
547
|
-
await atomicWrite(filePath, jsonText(next));
|
|
548
|
-
await maybeAudit(filePath, options);
|
|
549
|
-
return { path: filePath, written: true, revision: currentRevision + 1, stamped: next };
|
|
550
|
-
}
|
|
536
|
+
const write = async (): Promise<GuardedWriteResult> => {
|
|
537
|
+
const current = await readJsonIfPresentTolerant(filePath);
|
|
538
|
+
const currentRevision = persistedStateRevision(current);
|
|
551
539
|
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
options.
|
|
555
|
-
(valueSourceRevision || (options.advanceSourceRevision ? persistedSourceRevision(current) + 1 : 0));
|
|
556
|
-
if (current !== undefined && incomingSourceRevision <= persistedSourceRevision(current)) {
|
|
557
|
-
return { path: filePath, written: false, reason: "stale-skip", revision: currentRevision };
|
|
540
|
+
if (options.policy === "source") {
|
|
541
|
+
if (options.expectedRevision !== undefined && options.expectedRevision !== currentRevision) {
|
|
542
|
+
throw new StateWriteConflictError(filePath, options.expectedRevision, currentRevision);
|
|
558
543
|
}
|
|
559
|
-
const next = stampStateRevision(
|
|
560
|
-
withWorkflowReceipt(value, buildReceipt(options)),
|
|
561
|
-
currentRevision + 1,
|
|
562
|
-
incomingSourceRevision,
|
|
563
|
-
);
|
|
544
|
+
const next = stampStateRevision(withWorkflowReceipt(value, buildReceipt(options)), currentRevision + 1);
|
|
564
545
|
await atomicWrite(filePath, jsonText(next));
|
|
565
546
|
await maybeAudit(filePath, options);
|
|
566
547
|
return { path: filePath, written: true, revision: currentRevision + 1, stamped: next };
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const valueSourceRevision = isPlainObject(value) ? persistedSourceRevision(value) : 0;
|
|
551
|
+
const incomingSourceRevision =
|
|
552
|
+
options.sourceRevision ??
|
|
553
|
+
(valueSourceRevision || (options.advanceSourceRevision ? persistedSourceRevision(current) + 1 : 0));
|
|
554
|
+
if (current !== undefined && incomingSourceRevision <= persistedSourceRevision(current)) {
|
|
555
|
+
return { path: filePath, written: false, reason: "stale-skip", revision: currentRevision };
|
|
556
|
+
}
|
|
557
|
+
const next = stampStateRevision(
|
|
558
|
+
withWorkflowReceipt(value, buildReceipt(options)),
|
|
559
|
+
currentRevision + 1,
|
|
560
|
+
incomingSourceRevision,
|
|
561
|
+
);
|
|
562
|
+
await atomicWrite(filePath, jsonText(next));
|
|
563
|
+
await maybeAudit(filePath, options);
|
|
564
|
+
return { path: filePath, written: true, revision: currentRevision + 1, stamped: next };
|
|
565
|
+
};
|
|
566
|
+
// `withFileLock` deliberately serializes same-process callers too, so a caller
|
|
567
|
+
// that already holds this target's lock (via `withWorkflowStateLock`) must say
|
|
568
|
+
// so rather than deadlocking against itself — the same `lockHeld` contract the
|
|
569
|
+
// envelope writers already use.
|
|
570
|
+
return options.lockHeld ? write() : lockResolvedWorkflowTarget(filePath, write, options.lock);
|
|
570
571
|
}
|
|
571
572
|
|
|
572
573
|
export async function writeGuardedJsonAtomic(
|