@gajae-code/coding-agent 0.16.3 → 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 +113 -0
- package/dist/types/capability/types.d.ts +4 -0
- package/dist/types/cli/args.d.ts +2 -2
- package/dist/types/cli/customize-doctor.d.ts +22 -0
- package/dist/types/cli/root-flags.d.ts +4 -2
- package/dist/types/cli.d.ts +1 -1
- package/dist/types/commands/launch.d.ts +1 -1
- package/dist/types/config/autorouting-tier-map.d.ts +3 -0
- package/dist/types/config/file-lock.d.ts +31 -3
- package/dist/types/config/model-registry.d.ts +4 -0
- package/dist/types/config/model-resolver.d.ts +9 -0
- package/dist/types/config/settings-schema.d.ts +29 -1
- 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/claude-plugins.d.ts +3 -1
- package/dist/types/discovery/helpers.d.ts +35 -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/mcp-policy.d.ts +16 -0
- package/dist/types/extensibility/gjc-plugins/paths.d.ts +2 -0
- 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 +5 -2
- package/dist/types/extensibility/shared-events.d.ts +1 -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 +14 -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/assistant-message.d.ts +1 -2
- 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/components/settings-selector.d.ts +6 -1
- package/dist/types/modes/components/status-line/command.d.ts +35 -0
- package/dist/types/modes/components/status-line/index.d.ts +1 -0
- package/dist/types/modes/components/status-line/types.d.ts +5 -0
- package/dist/types/modes/components/tool-status-header.d.ts +14 -0
- package/dist/types/modes/controllers/event-controller.d.ts +6 -0
- package/dist/types/modes/interactive-mode.d.ts +3 -0
- package/dist/types/modes/types.d.ts +7 -0
- package/dist/types/runtime-mcp/client.d.ts +9 -1
- package/dist/types/runtime-mcp/manager.d.ts +7 -1
- package/dist/types/runtime-mcp/pool.d.ts +22 -3
- package/dist/types/runtime-mcp/transports/stdio.d.ts +0 -4
- package/dist/types/runtime-mcp/types.d.ts +22 -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-daemon-runtime.d.ts +12 -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/slack-live-provider.d.ts +6 -0
- package/dist/types/sdk/bus/slack-provider.d.ts +12 -0
- 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 +26 -1
- 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-api-key.d.ts +26 -0
- package/dist/types/sdk/session.d.ts +5 -1
- package/dist/types/sdk/transport/serve-cli.d.ts +1 -1
- package/dist/types/session/agent-session.d.ts +11 -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 +112 -0
- package/dist/types/setup/paseo/setup-deps.d.ts +2 -1
- package/dist/types/skill-state/canonical-skills.d.ts +4 -0
- package/dist/types/system-prompt.d.ts +10 -0
- package/dist/types/task/executor.d.ts +5 -1
- package/dist/types/tools/bash.d.ts +1 -0
- package/dist/types/tools/fetch.d.ts +7 -0
- package/dist/types/tools/github-cache.d.ts +2 -1
- package/dist/types/tools/index.d.ts +10 -0
- package/dist/types/tools/path-utils.d.ts +2 -0
- package/dist/types/tools/search.d.ts +6 -0
- package/package.json +9 -7
- package/scripts/capture-status-line-custom-editor-showcase.ts +151 -0
- package/scripts/generate-sdk-operation-inventory.ts +11 -0
- package/scripts/verify-status-line-custom-editor-showcase.ts +309 -0
- package/src/capability/index.ts +14 -2
- package/src/capability/types.ts +4 -0
- package/src/cli/args.ts +20 -9
- package/src/cli/customize-doctor.ts +155 -12
- package/src/cli/root-flags.ts +5 -3
- package/src/cli.ts +22 -0
- package/src/commands/sdk.ts +74 -31
- package/src/config/autorouting-tier-map.ts +1 -0
- package/src/config/file-lock-gc.ts +40 -2
- package/src/config/file-lock.ts +379 -31
- package/src/config/model-preset-registry.ts +120 -19
- package/src/config/model-profiles.ts +40 -0
- package/src/config/model-registry.ts +73 -23
- package/src/config/model-resolver.ts +48 -0
- package/src/config/settings-schema.ts +33 -1
- package/src/config/settings.ts +1 -0
- 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/extensions/grok-cli-vendor/src/models/catalog.ts +11 -11
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/register.ts +13 -2
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +22 -0
- package/src/discovery/builtin.ts +52 -55
- package/src/discovery/claude-plugins.ts +12 -4
- package/src/discovery/helpers.ts +307 -34
- 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 +228 -60
- package/src/extensibility/gjc-plugins/constrained-hooks.ts +321 -23
- package/src/extensibility/gjc-plugins/installer.ts +13 -12
- package/src/extensibility/gjc-plugins/mcp-policy.ts +81 -52
- package/src/extensibility/gjc-plugins/paths.ts +13 -0
- package/src/extensibility/gjc-plugins/registry.ts +113 -1
- package/src/extensibility/gjc-plugins/runtime-adapters.ts +758 -16
- 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 +149 -190
- package/src/extensibility/shared-events.ts +1 -1
- package/src/extensibility/skill-management.ts +200 -52
- package/src/extensibility/skills.ts +73 -25
- 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/repository-binding.ts +1 -1
- package/src/gjc-runtime/session-state-lock.ts +143 -10
- 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 +12 -12
- 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 +78 -35
- package/src/modes/DESIGN.md +54 -0
- package/src/modes/acp/acp-agent.ts +16 -3
- package/src/modes/components/assistant-message.ts +22 -9
- package/src/modes/components/bash-execution.ts +4 -1
- package/src/modes/components/execution-shared.ts +4 -1
- package/src/modes/components/model-selector.ts +166 -33
- package/src/modes/components/session-observer-overlay.ts +1 -1
- package/src/modes/components/settings-selector.ts +1075 -235
- package/src/modes/components/status-line/command.ts +190 -0
- package/src/modes/components/status-line/index.ts +1 -0
- package/src/modes/components/status-line/segments.ts +32 -5
- package/src/modes/components/status-line/types.ts +5 -0
- package/src/modes/components/tool-status-header.ts +396 -22
- package/src/modes/controllers/command-controller.ts +3 -0
- package/src/modes/controllers/event-controller.ts +151 -7
- package/src/modes/controllers/extension-ui-controller.ts +1 -0
- package/src/modes/controllers/input-controller.ts +6 -3
- package/src/modes/controllers/selector-controller.ts +21 -0
- package/src/modes/interactive-mode.ts +53 -6
- 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 +17 -3
- package/src/modes/utils/ui-helpers.ts +2 -0
- package/src/prompts/system/system-prompt.md +8 -0
- package/src/prompts/tools/search.md +2 -0
- package/src/prompts/tools/subagent.md +1 -0
- package/src/runtime-mcp/client.ts +60 -17
- package/src/runtime-mcp/manager.ts +189 -14
- package/src/runtime-mcp/pool.test.ts +218 -0
- package/src/runtime-mcp/pool.ts +224 -23
- package/src/runtime-mcp/transports/stdio.ts +158 -24
- package/src/runtime-mcp/types.ts +24 -0
- package/src/sdk/broker/broker.ts +124 -32
- 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 +72 -6
- 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 +478 -49
- package/src/sdk/bus/slack-live-provider.ts +12 -0
- package/src/sdk/bus/slack-provider.ts +5 -0
- 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 +11 -8
- 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 +52 -10
- package/src/sdk/host/session-runtime.ts +383 -44
- package/src/sdk/lifecycle/service.ts +4 -0
- package/src/sdk/protocol/operation-inventory.generated.json +77 -0
- package/src/sdk/router/session-router.ts +89 -38
- package/src/sdk/session-api-key.ts +94 -0
- package/src/sdk/session.ts +228 -55
- package/src/sdk/transport/relay.ts +35 -13
- package/src/sdk/transport/serve-cli.ts +3 -3
- package/src/session/agent-session.ts +240 -90
- package/src/session/contribution-prep.ts +214 -21
- 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 +456 -0
- package/src/setup/paseo/setup-deps.ts +15 -3
- package/src/skill-state/canonical-skills.ts +12 -0
- package/src/skills/skills.test.ts +41 -21
- package/src/slash-commands/builtin-registry.ts +1 -0
- package/src/system-prompt.ts +29 -3
- package/src/task/executor.ts +7 -1
- package/src/task/index.ts +21 -4
- package/src/tools/bash.ts +63 -19
- package/src/tools/cron.ts +1 -1
- package/src/tools/fetch.ts +29 -1
- package/src/tools/gh.ts +26 -3
- package/src/tools/github-cache.ts +68 -16
- package/src/tools/index.ts +10 -0
- package/src/tools/job.ts +21 -4
- package/src/tools/monitor.ts +8 -2
- package/src/tools/path-utils.ts +7 -0
- 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/search.ts +116 -58
- package/src/tools/skill-discovery.ts +5 -0
- package/src/tools/skill.ts +37 -2
- package/src/tools/subagent.ts +6 -3
- package/src/tools/tool-catalog.generated.ts +10 -3
- package/src/tools/ultragoal-ask-guard.ts +2 -2
- package/src/web/insane/bridge.ts +113 -23
- package/src/web/search/providers/perplexity.ts +102 -41
|
@@ -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" };
|
|
@@ -106,7 +106,7 @@ function normalizeRelativeSubdir(relative: string): string {
|
|
|
106
106
|
.replaceAll("\\", "/")
|
|
107
107
|
.replace(/^\.\/+/u, "")
|
|
108
108
|
.replace(/\/+$/u, "");
|
|
109
|
-
if (normalized === ""
|
|
109
|
+
if (normalized === "") {
|
|
110
110
|
throw new RepositoryBindingError("invalid_binding", "relativeSubdir must be a non-empty relative path");
|
|
111
111
|
}
|
|
112
112
|
if (path.isAbsolute(normalized) || normalized.split("/").includes("..")) {
|
|
@@ -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 {
|
|
@@ -146,7 +148,44 @@ interface PendingTransitionRelease {
|
|
|
146
148
|
recovery?: Promise<boolean>;
|
|
147
149
|
}
|
|
148
150
|
|
|
151
|
+
interface LocalTransitionQueue {
|
|
152
|
+
waiters: Array<() => void>;
|
|
153
|
+
}
|
|
154
|
+
|
|
149
155
|
const pendingTransitionReleases = new Map<string, PendingTransitionRelease>();
|
|
156
|
+
const localTransitionQueues = new Map<string, LocalTransitionQueue>();
|
|
157
|
+
|
|
158
|
+
async function joinLocalTransitionQueue(
|
|
159
|
+
key: string,
|
|
160
|
+
transitionDir: string,
|
|
161
|
+
retryBudget?: LockRetryBudget,
|
|
162
|
+
): Promise<LocalTransitionQueue | undefined> {
|
|
163
|
+
const queue = localTransitionQueues.get(key);
|
|
164
|
+
if (!queue) return undefined;
|
|
165
|
+
if (retryBudget?.nonBlocking) throw SESSION_STATE_LOCK_BUSY;
|
|
166
|
+
const ready = Promise.withResolvers<void>();
|
|
167
|
+
const queuedAt = performance.now();
|
|
168
|
+
SessionStateLockTestHooks.afterLocalTransitionQueued?.(transitionDir);
|
|
169
|
+
queue.waiters.push(ready.resolve);
|
|
170
|
+
await ready.promise;
|
|
171
|
+
if (retryBudget) retryBudget.startedAt += Math.max(0, performance.now() - queuedAt);
|
|
172
|
+
return queue;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function claimLocalTransitionQueue(key: string): LocalTransitionQueue {
|
|
176
|
+
const queue: LocalTransitionQueue = { waiters: [] };
|
|
177
|
+
localTransitionQueues.set(key, queue);
|
|
178
|
+
return queue;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function releaseLocalTransitionQueue(key: string, queue: LocalTransitionQueue): void {
|
|
182
|
+
const next = queue.waiters.shift();
|
|
183
|
+
if (next) {
|
|
184
|
+
next();
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (localTransitionQueues.get(key) === queue) localTransitionQueues.delete(key);
|
|
188
|
+
}
|
|
150
189
|
|
|
151
190
|
function clearPendingTransitionRelease(key: string, pending?: PendingTransitionRelease): void {
|
|
152
191
|
const current = pendingTransitionReleases.get(key);
|
|
@@ -413,6 +452,8 @@ export const SessionStateLockTestHooks: {
|
|
|
413
452
|
forcedQuarantineName?: string;
|
|
414
453
|
/** @internal Observes bounded acquisition retries without changing their timing. */
|
|
415
454
|
afterAcquireContention?: (lockFile: string, attempt: number, elapsedMs: number) => void;
|
|
455
|
+
/** @internal Observes process-local admission without altering on-disk ownership. */
|
|
456
|
+
afterLocalTransitionQueued?: (transitionDir: string) => void;
|
|
416
457
|
/** @internal Runs after transition mkdir contention and before stale-claim inspection. */
|
|
417
458
|
afterTransitionClaimContention?: (transitionDir: string) => void | Promise<void>;
|
|
418
459
|
} = {};
|
|
@@ -1781,6 +1822,7 @@ async function recoverPendingTransitionRelease(
|
|
|
1781
1822
|
transitionDir: string,
|
|
1782
1823
|
recoveryKey: string,
|
|
1783
1824
|
quarantineName: string,
|
|
1825
|
+
nonBlocking = false,
|
|
1784
1826
|
): Promise<boolean> {
|
|
1785
1827
|
let pendingKey = recoveryKey;
|
|
1786
1828
|
let pending = pendingTransitionReleases.get(pendingKey);
|
|
@@ -1813,7 +1855,10 @@ async function recoverPendingTransitionRelease(
|
|
|
1813
1855
|
// for a contender while that release is still in progress. The owner that armed
|
|
1814
1856
|
// it flips this bit only when a phase actually throws.
|
|
1815
1857
|
if (!pending.recoverable) return false;
|
|
1816
|
-
if (pending.recovery)
|
|
1858
|
+
if (pending.recovery) {
|
|
1859
|
+
if (nonBlocking) throw SESSION_STATE_LOCK_BUSY;
|
|
1860
|
+
return await pending.recovery;
|
|
1861
|
+
}
|
|
1817
1862
|
const recovery = (async (): Promise<boolean> => {
|
|
1818
1863
|
const ownerFile = `${transitionDir}.owner`;
|
|
1819
1864
|
if (!pending.generation) {
|
|
@@ -1993,7 +2038,14 @@ async function recoverPendingTransitionRelease(
|
|
|
1993
2038
|
}
|
|
1994
2039
|
}
|
|
1995
2040
|
|
|
1996
|
-
/**
|
|
2041
|
+
/**
|
|
2042
|
+
* Run one pathname transition under an atomic `mkdir`/`rmdir` claim.
|
|
2043
|
+
*
|
|
2044
|
+
* Same-process contenders join the current claim owner's lifetime before consuming their
|
|
2045
|
+
* bounded filesystem-contention budget. The on-disk protocol remains authoritative across
|
|
2046
|
+
* processes; this queue only prevents a descheduled local owner from timing out its own
|
|
2047
|
+
* successor while it still legitimately holds the claim.
|
|
2048
|
+
*/
|
|
1997
2049
|
async function withLockPathTransition<T>(
|
|
1998
2050
|
lockFile: string,
|
|
1999
2051
|
transition: () => Promise<T>,
|
|
@@ -2003,25 +2055,75 @@ async function withLockPathTransition<T>(
|
|
|
2003
2055
|
if (ownerAccessStrategy() === "unsupported")
|
|
2004
2056
|
throw new SessionStateLockUnavailableError(new Error("Safe transition ownership is unsupported."));
|
|
2005
2057
|
const transitionDir = `${lockFile}${LOCK_TRANSITION_RESOURCE_SUFFIX}`;
|
|
2006
|
-
const ownerFile = `${transitionDir}.owner`;
|
|
2007
2058
|
const recoveryKey = await transitionRecoveryKey(transitionDir);
|
|
2059
|
+
const localKey = recoveryKey;
|
|
2008
2060
|
const owner = await newLockOwner();
|
|
2009
2061
|
const budget = retryBudget ?? lockRetryBudget();
|
|
2062
|
+
let localTurn = await joinLocalTransitionQueue(localKey, transitionDir, budget);
|
|
2063
|
+
const claimLocalTurn = (): void => {
|
|
2064
|
+
localTurn ??= claimLocalTransitionQueue(localKey);
|
|
2065
|
+
};
|
|
2066
|
+
const joinLocalTurn = async (): Promise<boolean> => {
|
|
2067
|
+
if (localTurn) return false;
|
|
2068
|
+
const joined = await joinLocalTransitionQueue(localKey, transitionDir, budget);
|
|
2069
|
+
if (!joined) return false;
|
|
2070
|
+
localTurn = joined;
|
|
2071
|
+
return true;
|
|
2072
|
+
};
|
|
2073
|
+
try {
|
|
2074
|
+
return await runLockPathTransition(
|
|
2075
|
+
lockFile,
|
|
2076
|
+
recoveryKey,
|
|
2077
|
+
owner,
|
|
2078
|
+
budget,
|
|
2079
|
+
claimLocalTurn,
|
|
2080
|
+
joinLocalTurn,
|
|
2081
|
+
transition,
|
|
2082
|
+
quarantineName,
|
|
2083
|
+
);
|
|
2084
|
+
} finally {
|
|
2085
|
+
if (localTurn) releaseLocalTransitionQueue(localKey, localTurn);
|
|
2086
|
+
}
|
|
2087
|
+
}
|
|
2088
|
+
|
|
2089
|
+
async function runLockPathTransition<T>(
|
|
2090
|
+
lockFile: string,
|
|
2091
|
+
recoveryKey: string,
|
|
2092
|
+
owner: SessionStateLockOwner,
|
|
2093
|
+
budget: LockRetryBudget,
|
|
2094
|
+
claimLocalTurn: () => void,
|
|
2095
|
+
joinLocalTurn: () => Promise<boolean>,
|
|
2096
|
+
transition: () => Promise<T>,
|
|
2097
|
+
quarantineName = lockQuarantineName(),
|
|
2098
|
+
): Promise<T> {
|
|
2099
|
+
if (ownerAccessStrategy() === "unsupported")
|
|
2100
|
+
throw new SessionStateLockUnavailableError(new Error("Safe transition ownership is unsupported."));
|
|
2101
|
+
const transitionDir = `${lockFile}${LOCK_TRANSITION_RESOURCE_SUFFIX}`;
|
|
2102
|
+
const ownerFile = `${transitionDir}.owner`;
|
|
2103
|
+
|
|
2010
2104
|
for (;;) {
|
|
2011
2105
|
if (ownerAccessStrategy() === "unsupported" && fsSync.existsSync(transitionDir))
|
|
2012
2106
|
throw new SessionStateLockUnavailableError(new Error("Safe transition ownership is unsupported."));
|
|
2013
|
-
if (await recoverPendingTransitionRelease(transitionDir, recoveryKey, quarantineName)) {
|
|
2107
|
+
if (await recoverPendingTransitionRelease(transitionDir, recoveryKey, quarantineName, budget.nonBlocking)) {
|
|
2014
2108
|
// The claim this process stranded in an earlier failed release is gone;
|
|
2015
2109
|
// fall through and retry the mkdir immediately.
|
|
2016
2110
|
}
|
|
2111
|
+
if (await joinLocalTurn()) continue;
|
|
2017
2112
|
try {
|
|
2018
2113
|
await fs.mkdir(transitionDir, { mode: 0o700 });
|
|
2114
|
+
claimLocalTurn();
|
|
2019
2115
|
await fs.chmod(transitionDir, 0o700);
|
|
2020
2116
|
} catch (error) {
|
|
2021
2117
|
const code = (error as NodeJS.ErrnoException).code;
|
|
2022
|
-
if (code !== "EEXIST" && !isTransientLockError(error))
|
|
2118
|
+
if (code !== "EEXIST" && (budget.nonBlocking || !isTransientLockError(error)))
|
|
2119
|
+
throw new SessionStateLockUnavailableError(error);
|
|
2120
|
+
if (await joinLocalTurn()) continue;
|
|
2023
2121
|
await SessionStateLockTestHooks.afterTransitionClaimContention?.(transitionDir);
|
|
2024
|
-
|
|
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) {
|
|
2025
2127
|
if (lockRetryExhausted(budget))
|
|
2026
2128
|
throw lockUnavailable(transitionDir, "transition_claim_timeout", budget, error);
|
|
2027
2129
|
continue;
|
|
@@ -2461,8 +2563,33 @@ export async function reclaimStaleSessionStateLock(lockFile: string, quarantineN
|
|
|
2461
2563
|
* lock file itself and may run for as long as it needs.
|
|
2462
2564
|
*/
|
|
2463
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> {
|
|
2464
2592
|
const lockFile = `${stateFile}.lock`;
|
|
2465
|
-
const budget = lockRetryBudget();
|
|
2466
2593
|
let owner: SessionStateLockOwner;
|
|
2467
2594
|
try {
|
|
2468
2595
|
owner = await newLockOwner();
|
|
@@ -2518,6 +2645,11 @@ export async function withSessionStateFileLock<T>(stateFile: string, operation:
|
|
|
2518
2645
|
if (callbackFailure && error === callbackError) throw error;
|
|
2519
2646
|
// A fault after the lock was taken belongs to the operation, not to acquisition.
|
|
2520
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);
|
|
2521
2653
|
// Without a safe owner-record access strategy, retrying cannot make the
|
|
2522
2654
|
// transition claim removable. Preserve it as a fail-closed fence instead
|
|
2523
2655
|
// of spinning until the acquisition budget expires.
|
|
@@ -2540,6 +2672,7 @@ export async function withSessionStateFileLock<T>(stateFile: string, operation:
|
|
|
2540
2672
|
);
|
|
2541
2673
|
if (reclaim === "owner_unprovenanced")
|
|
2542
2674
|
throw lockUnavailable(lockFile, "lock_owner_record_unprovenanced", budget);
|
|
2675
|
+
if (budget.nonBlocking) throw SESSION_STATE_LOCK_BUSY;
|
|
2543
2676
|
if (reclaim === "legacy_directory_unprovenanced") lastReason = "legacy_directory_owner_unprovenanced";
|
|
2544
2677
|
else if (reclaim === "owner_live_or_unverifiable") lastReason = "lock_owner_live_or_unverifiable";
|
|
2545
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(
|