@gajae-code/coding-agent 0.13.1 → 0.13.3
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 +41 -0
- package/dist/types/config/model-registry.d.ts +1 -0
- package/dist/types/config/settings-schema.d.ts +3 -3
- package/dist/types/main.d.ts +1 -0
- package/dist/types/modes/components/dynamic-theme-text.d.ts +9 -0
- package/dist/types/modes/components/gajae-pet-widget.d.ts +5 -5
- package/dist/types/modes/components/pet-capability.d.ts +5 -5
- package/dist/types/modes/components/settings-selector.d.ts +5 -0
- package/dist/types/modes/theme/defaults/index.d.ts +118 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +3 -1
- package/dist/types/session/internal/managed-session-storage.d.ts +13 -0
- package/dist/types/session/session-manager.d.ts +7 -0
- package/dist/types/tools/image-gen.d.ts +1 -0
- package/package.json +8 -8
- package/src/config/model-profile-activation.ts +3 -1
- package/src/config/model-profiles.ts +59 -2
- package/src/config/model-registry.ts +16 -1
- package/src/config/settings-schema.ts +1 -1
- package/src/coordinator-mcp/server.ts +3 -0
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.ts +31 -6
- package/src/internal-urls/docs-index.generated.ts +7 -6
- package/src/main.ts +4 -0
- package/src/modes/components/chrome.ts +4 -3
- package/src/modes/components/dynamic-theme-text.ts +22 -0
- package/src/modes/components/gajae-pet-widget.ts +130 -42
- package/src/modes/components/model-selector.ts +1 -6
- package/src/modes/components/pet-capability.ts +10 -5
- package/src/modes/components/settings-selector.ts +20 -5
- package/src/modes/components/status-line/segments.ts +3 -3
- package/src/modes/controllers/selector-controller.ts +61 -5
- package/src/modes/interactive-mode.ts +4 -0
- package/src/modes/theme/defaults/index.ts +2 -0
- package/src/modes/theme/defaults/ouroboros.json +118 -0
- package/src/sdk/broker/process-incarnation.ts +6 -1
- package/src/sdk/bus/telegram-daemon-contract.ts +3 -1
- package/src/sdk/bus/telegram-daemon.ts +51 -43
- package/src/session/agent-session.ts +39 -0
- package/src/session/internal/managed-session-storage.ts +162 -40
- package/src/session/session-manager.ts +28 -0
- package/src/slash-commands/builtin-registry.ts +62 -8
- package/src/task/index.ts +5 -2
- package/src/tools/bash.ts +11 -9
- package/src/tools/image-gen.ts +322 -126
- package/src/tools/todo-contract.ts +55 -3
- package/src/tools/todo-write.ts +4 -3
|
@@ -3,6 +3,7 @@ import claude_code from "./claude-code.json" with { type: "json" };
|
|
|
3
3
|
import codex from "./codex.json" with { type: "json" };
|
|
4
4
|
import gruvbox_dark from "./gruvbox-dark.json" with { type: "json" };
|
|
5
5
|
import opencode from "./opencode.json" with { type: "json" };
|
|
6
|
+
import ouroboros from "./ouroboros.json" with { type: "json" };
|
|
6
7
|
import red_claw from "./red-claw.json" with { type: "json" };
|
|
7
8
|
|
|
8
9
|
export const defaultThemes = {
|
|
@@ -11,5 +12,6 @@ export const defaultThemes = {
|
|
|
11
12
|
codex,
|
|
12
13
|
"gruvbox-dark": gruvbox_dark,
|
|
13
14
|
opencode,
|
|
15
|
+
ouroboros,
|
|
14
16
|
"red-claw": red_claw,
|
|
15
17
|
};
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://raw.githubusercontent.com/can1357/gajae-code/main/packages/coding-agent/theme-schema.json",
|
|
3
|
+
"name": "ouroboros",
|
|
4
|
+
"vars": {
|
|
5
|
+
"cyan": "#58d5c3",
|
|
6
|
+
"blue": "#7092be",
|
|
7
|
+
"green": "#78c878",
|
|
8
|
+
"red": "#ff667a",
|
|
9
|
+
"yellow": "#f2c94c",
|
|
10
|
+
"gray": "#a9bbb2",
|
|
11
|
+
"dimGray": "#5b756d",
|
|
12
|
+
"darkGray": "#29443f",
|
|
13
|
+
"accent": "#aee80e",
|
|
14
|
+
"selectedBg": "#1b3b3b",
|
|
15
|
+
"userMsgBg": "#102b30",
|
|
16
|
+
"toolPendingBg": "#0d252b",
|
|
17
|
+
"toolSuccessBg": "#163326",
|
|
18
|
+
"toolErrorBg": "#351923",
|
|
19
|
+
"customMsgBg": "#0d2130",
|
|
20
|
+
"abyss": "#050b18",
|
|
21
|
+
"deepNavy": "#0b1630",
|
|
22
|
+
"mantle": "#0d2130",
|
|
23
|
+
"surface": "#102b30",
|
|
24
|
+
"surfaceBright": "#1b3b3b",
|
|
25
|
+
"ouroTeal": "#38bfa7",
|
|
26
|
+
"serpentGreen": "#78c878",
|
|
27
|
+
"petLime": "#aee80e",
|
|
28
|
+
"ritualGold": "#f2c94c",
|
|
29
|
+
"eyeGold": "#ffe786",
|
|
30
|
+
"coolScale": "#7092be",
|
|
31
|
+
"foam": "#e7f1e9",
|
|
32
|
+
"mutedScale": "#a9bbb2",
|
|
33
|
+
"dimScale": "#5b756d",
|
|
34
|
+
"dangerRed": "#ff667a",
|
|
35
|
+
"diffRemovalRed": "#df5b6b"
|
|
36
|
+
},
|
|
37
|
+
"colors": {
|
|
38
|
+
"accent": "petLime",
|
|
39
|
+
"border": "ouroTeal",
|
|
40
|
+
"borderAccent": "ritualGold",
|
|
41
|
+
"borderMuted": "dimScale",
|
|
42
|
+
"success": "serpentGreen",
|
|
43
|
+
"error": "dangerRed",
|
|
44
|
+
"warning": "ritualGold",
|
|
45
|
+
"muted": "mutedScale",
|
|
46
|
+
"dim": "dimScale",
|
|
47
|
+
"text": "foam",
|
|
48
|
+
"thinkingText": "mutedScale",
|
|
49
|
+
"selectedBg": "surfaceBright",
|
|
50
|
+
"userMessageBg": "surface",
|
|
51
|
+
"userMessageText": "foam",
|
|
52
|
+
"customMessageBg": "mantle",
|
|
53
|
+
"customMessageText": "foam",
|
|
54
|
+
"customMessageLabel": "petLime",
|
|
55
|
+
"toolPendingBg": "mantle",
|
|
56
|
+
"toolSuccessBg": "#163326",
|
|
57
|
+
"toolErrorBg": "#351923",
|
|
58
|
+
"toolTitle": "foam",
|
|
59
|
+
"toolOutput": "mutedScale",
|
|
60
|
+
"mdHeading": "petLime",
|
|
61
|
+
"mdLink": "ouroTeal",
|
|
62
|
+
"mdLinkUrl": "dimScale",
|
|
63
|
+
"mdCode": "eyeGold",
|
|
64
|
+
"mdCodeBlock": "serpentGreen",
|
|
65
|
+
"mdCodeBlockBorder": "ouroTeal",
|
|
66
|
+
"mdQuote": "mutedScale",
|
|
67
|
+
"mdQuoteBorder": "ouroTeal",
|
|
68
|
+
"mdHr": "dimScale",
|
|
69
|
+
"mdListBullet": "ritualGold",
|
|
70
|
+
"toolDiffAdded": "serpentGreen",
|
|
71
|
+
"toolDiffRemoved": "diffRemovalRed",
|
|
72
|
+
"toolDiffContext": "mutedScale",
|
|
73
|
+
"syntaxComment": "dimScale",
|
|
74
|
+
"syntaxKeyword": "petLime",
|
|
75
|
+
"syntaxFunction": "eyeGold",
|
|
76
|
+
"syntaxVariable": "foam",
|
|
77
|
+
"syntaxString": "serpentGreen",
|
|
78
|
+
"syntaxNumber": "coolScale",
|
|
79
|
+
"syntaxType": "ouroTeal",
|
|
80
|
+
"syntaxOperator": "ritualGold",
|
|
81
|
+
"syntaxPunctuation": "mutedScale",
|
|
82
|
+
"thinkingOff": "dimScale",
|
|
83
|
+
"thinkingMinimal": "mutedScale",
|
|
84
|
+
"thinkingLow": "coolScale",
|
|
85
|
+
"thinkingMedium": "ouroTeal",
|
|
86
|
+
"thinkingHigh": "petLime",
|
|
87
|
+
"thinkingXhigh": "eyeGold",
|
|
88
|
+
"bashMode": "petLime",
|
|
89
|
+
"statusLineBg": "deepNavy",
|
|
90
|
+
"statusLineSep": "dimScale",
|
|
91
|
+
"statusLineModel": "petLime",
|
|
92
|
+
"statusLinePath": "ouroTeal",
|
|
93
|
+
"statusLineGitClean": "serpentGreen",
|
|
94
|
+
"statusLineGitDirty": "ritualGold",
|
|
95
|
+
"statusLineContext": "coolScale",
|
|
96
|
+
"statusLineSpend": "eyeGold",
|
|
97
|
+
"statusLineStaged": "serpentGreen",
|
|
98
|
+
"statusLineDirty": "ritualGold",
|
|
99
|
+
"statusLineUntracked": "diffRemovalRed",
|
|
100
|
+
"statusLineOutput": "foam",
|
|
101
|
+
"statusLineCost": "eyeGold",
|
|
102
|
+
"statusLineSubagents": "ouroTeal",
|
|
103
|
+
"pythonMode": "ritualGold"
|
|
104
|
+
},
|
|
105
|
+
"export": {
|
|
106
|
+
"pageBg": "#050b18",
|
|
107
|
+
"cardBg": "#0b1630",
|
|
108
|
+
"infoBg": "#102b30"
|
|
109
|
+
},
|
|
110
|
+
"symbols": {
|
|
111
|
+
"preset": "unicode",
|
|
112
|
+
"overrides": {
|
|
113
|
+
"icon.pi": "🐍",
|
|
114
|
+
"status.running": "∞",
|
|
115
|
+
"md.bullet": "◇"
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -40,7 +40,12 @@ export interface ProcessIncarnationOptions {
|
|
|
40
40
|
|
|
41
41
|
function runProcessIncarnationCommand(command: string, args: readonly string[]): ProcessIncarnationCommandResult {
|
|
42
42
|
try {
|
|
43
|
-
const result = Bun.spawnSync([command, ...args], {
|
|
43
|
+
const result = Bun.spawnSync([command, ...args], {
|
|
44
|
+
stdin: "ignore",
|
|
45
|
+
stdout: "pipe",
|
|
46
|
+
stderr: "ignore",
|
|
47
|
+
windowsHide: true,
|
|
48
|
+
});
|
|
44
49
|
return { exitCode: result.exitCode, stdout: Buffer.from(result.stdout).toString("utf8") };
|
|
45
50
|
} catch {
|
|
46
51
|
return undefined;
|
|
@@ -105,8 +105,10 @@ export const NOTIFICATION_PROTOCOL_VERSION = 3;
|
|
|
105
105
|
* settles non-forum close responses. Generation 62 rejects replay-gap
|
|
106
106
|
* authority claims that do not match the requested cursor, answer bounds,
|
|
107
107
|
* and retained replay suffix.
|
|
108
|
+
* Generation 63 contains transient steady-heartbeat state and ownership-lock
|
|
109
|
+
* read failures and cleans every staged sidecar before retrying (#4200).
|
|
108
110
|
*/
|
|
109
|
-
export const DAEMON_GENERATION =
|
|
111
|
+
export const DAEMON_GENERATION = 63;
|
|
110
112
|
|
|
111
113
|
/**
|
|
112
114
|
* Serving-compatibility boundary for daemon lifecycle requests. Epoch 5
|
|
@@ -1419,38 +1419,38 @@ export async function renewOwnerHeartbeatSidecar(input: {
|
|
|
1419
1419
|
logger.warn(`notifications: heartbeat sidecar staging failed: ${sanitizeDiagnostic(String(error))}`);
|
|
1420
1420
|
return "publish_failed";
|
|
1421
1421
|
}
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
await
|
|
1447
|
-
logger.warn(
|
|
1448
|
-
`notifications: heartbeat sidecar publication failed after ${attempt} attempt(s): ${sanitizeDiagnostic(String(error))}`,
|
|
1449
|
-
);
|
|
1450
|
-
return "publish_failed";
|
|
1422
|
+
try {
|
|
1423
|
+
const lock = await readOwnershipLock(fsImpl, paths.lock);
|
|
1424
|
+
if (!ownershipLockMatchesState(lock, state)) return "not_owner";
|
|
1425
|
+
for (let attempt = 1; ; attempt++) {
|
|
1426
|
+
// Revalidate the exact lock after the temporary sidecar exists — and again
|
|
1427
|
+
// before every retry — so a stale writer cannot publish after ownership
|
|
1428
|
+
// moved during its write or its backoff.
|
|
1429
|
+
const finalLock = await readOwnershipLock(fsImpl, paths.lock);
|
|
1430
|
+
if (!ownershipLockMatchesState(finalLock, state)) return "not_owner";
|
|
1431
|
+
try {
|
|
1432
|
+
await fsImpl.rename(tmp, paths.heartbeat);
|
|
1433
|
+
return "renewed";
|
|
1434
|
+
} catch (error) {
|
|
1435
|
+
// Windows: a transient external lock on the destination (antivirus,
|
|
1436
|
+
// indexer) surfaces as EPERM on rename and used to escape as an
|
|
1437
|
+
// uncaught exception that killed the whole daemon (#4200). Retry a
|
|
1438
|
+
// bounded number of times under the lock fence, then keep the daemon
|
|
1439
|
+
// alive and let the next heartbeat cycle publish.
|
|
1440
|
+
if (attempt >= HEARTBEAT_SIDECAR_PUBLISH_ATTEMPTS || !isTransientSidecarPublishError(error)) {
|
|
1441
|
+
logger.warn(
|
|
1442
|
+
`notifications: heartbeat sidecar publication failed after ${attempt} attempt(s): ${sanitizeDiagnostic(String(error))}`,
|
|
1443
|
+
);
|
|
1444
|
+
return "publish_failed";
|
|
1445
|
+
}
|
|
1446
|
+
await Bun.sleep(HEARTBEAT_SIDECAR_PUBLISH_BACKOFF_MS * attempt);
|
|
1451
1447
|
}
|
|
1452
|
-
await Bun.sleep(HEARTBEAT_SIDECAR_PUBLISH_BACKOFF_MS * attempt);
|
|
1453
1448
|
}
|
|
1449
|
+
} finally {
|
|
1450
|
+
// A thrown state or ownership-lock read after the staging write must not
|
|
1451
|
+
// leak the live-PID staging file. After a successful rename the staging
|
|
1452
|
+
// path no longer exists and this unlink is a no-op (#4200).
|
|
1453
|
+
await fsImpl.unlink(tmp).catch(() => undefined);
|
|
1454
1454
|
}
|
|
1455
1455
|
}
|
|
1456
1456
|
|
|
@@ -12490,19 +12490,27 @@ export class TelegramNotificationDaemon {
|
|
|
12490
12490
|
let idleSince = this.runtime.now();
|
|
12491
12491
|
while (this.running) {
|
|
12492
12492
|
if (await this.controlStopRequested()) break;
|
|
12493
|
-
|
|
12494
|
-
|
|
12495
|
-
|
|
12496
|
-
|
|
12497
|
-
|
|
12498
|
-
|
|
12499
|
-
|
|
12500
|
-
|
|
12501
|
-
|
|
12502
|
-
|
|
12503
|
-
|
|
12504
|
-
|
|
12505
|
-
|
|
12493
|
+
// Transient Windows state/lock read failures must not terminate the
|
|
12494
|
+
// notification daemon. Only a proven ownership mismatch stops it.
|
|
12495
|
+
let ownerHeld = true;
|
|
12496
|
+
try {
|
|
12497
|
+
ownerHeld =
|
|
12498
|
+
(await renewOwnerHeartbeatSidecar({
|
|
12499
|
+
settings: this.opts.settings,
|
|
12500
|
+
ownerId: this.opts.ownerId,
|
|
12501
|
+
acquisitionId: this.opts.ownerId,
|
|
12502
|
+
fs: this.fsImpl,
|
|
12503
|
+
now: this.opts.now,
|
|
12504
|
+
pid: this.opts.pid ?? process.pid,
|
|
12505
|
+
pidIncarnation: this.opts.pidIncarnation,
|
|
12506
|
+
attachedEndpoints: this.attachedEndpointCount(),
|
|
12507
|
+
})) !== "not_owner";
|
|
12508
|
+
} catch (error) {
|
|
12509
|
+
logger.warn(
|
|
12510
|
+
`notifications: ownership heartbeat renewal threw; continuing: ${sanitizeDiagnostic(String(error))}`,
|
|
12511
|
+
);
|
|
12512
|
+
}
|
|
12513
|
+
if (!ownerHeld) break;
|
|
12506
12514
|
await this.runScan();
|
|
12507
12515
|
if (await this.controlStopRequested()) break;
|
|
12508
12516
|
const idleElapsed = this.runtime.now() - idleSince >= (this.opts.idleTimeoutMs ?? 60_000);
|
|
@@ -954,6 +954,13 @@ type RetryErrorClassification =
|
|
|
954
954
|
|
|
955
955
|
const BARE_DEFAULT_WATCHDOG_ERROR =
|
|
956
956
|
/^(?:[A-Za-z][A-Za-z0-9-]*(?: [A-Za-z][A-Za-z0-9-]*){0,3} )stream (?:timed out while waiting for the first event|stalled while waiting for the next event)$/;
|
|
957
|
+
/**
|
|
958
|
+
* Consecutive same-model retries allowed for `escaped_arguments_discarded`
|
|
959
|
+
* managed outcomes before the run terminates. Mirrors the agent loop's
|
|
960
|
+
* escaped-non-ASCII resample budget on dev; enforced session-side here because
|
|
961
|
+
* a managed retry re-enters the loop as a fresh run.
|
|
962
|
+
*/
|
|
963
|
+
const MAX_ESCAPED_ARGUMENT_RETRIES = 2;
|
|
957
964
|
const PROVIDER_FIRST_EVENT_TIMEOUT_ERROR = "Provider stream timed out while waiting for the first event";
|
|
958
965
|
const WRAPPED_PROVIDER_FIRST_EVENT_TIMEOUT_ERROR = `Error: ${PROVIDER_FIRST_EVENT_TIMEOUT_ERROR}`;
|
|
959
966
|
const PROVIDER_FIRST_EVENT_TIMEOUT_WITHOUT_ARTICLE_ERROR = "Provider stream timed out while waiting for first event";
|
|
@@ -2030,6 +2037,7 @@ export class AgentSession {
|
|
|
2030
2037
|
#retryPromise: Promise<void> | undefined = undefined;
|
|
2031
2038
|
#retryResolve: (() => void) | undefined = undefined;
|
|
2032
2039
|
#defaultFallbackController: FallbackChainController | undefined;
|
|
2040
|
+
#consecutiveEscapedArgumentRetries = 0;
|
|
2033
2041
|
#overflowMaintenanceAttempts = 0;
|
|
2034
2042
|
#defaultFallbackExhaustedLastTurn = false;
|
|
2035
2043
|
#fallbackInvocationId = 0;
|
|
@@ -13690,6 +13698,7 @@ export class AgentSession {
|
|
|
13690
13698
|
sessionMemory.metadataResidentBytes,
|
|
13691
13699
|
tuiChatChildren: retainedMemory.tuiChatChildren ?? 0,
|
|
13692
13700
|
tuiCachedRenderBytes: retainedMemory.tuiCachedRenderBytes ?? 0,
|
|
13701
|
+
transcriptFileBytes: this.sessionManager.getTranscriptFileBytes(),
|
|
13693
13702
|
};
|
|
13694
13703
|
}
|
|
13695
13704
|
|
|
@@ -15710,10 +15719,40 @@ export class AgentSession {
|
|
|
15710
15719
|
}
|
|
15711
15720
|
|
|
15712
15721
|
async #handleManagedAttemptOutcome(outcome: ManagedAttemptOutcome): Promise<ManagedAttemptDecision> {
|
|
15722
|
+
if (outcome.type !== "escaped_arguments_discarded") this.#consecutiveEscapedArgumentRetries = 0;
|
|
15713
15723
|
if (outcome.type === "run_terminal") {
|
|
15714
15724
|
this.#defaultFallbackChain().resetAttemptBudget();
|
|
15715
15725
|
return { type: "terminal", terminal: { stopReason: outcome.reason } };
|
|
15716
15726
|
}
|
|
15727
|
+
if (outcome.type === "escaped_arguments_discarded") {
|
|
15728
|
+
// An escaped-non-ASCII wire defect is a sampling accident, not provider
|
|
15729
|
+
// evidence: never charge the attempt, advance the chain, or suppress the
|
|
15730
|
+
// selector. The loop already removed the defective turn from history, so
|
|
15731
|
+
// this decision just re-issues the same request on the same model.
|
|
15732
|
+
//
|
|
15733
|
+
// The retry bound lives HERE, not in the loop: a managed retry re-enters
|
|
15734
|
+
// the loop as a fresh run, so no loop-local budget survives it. After
|
|
15735
|
+
// MAX_ESCAPED_ARGUMENT_RETRIES consecutive escaped-argument discards the
|
|
15736
|
+
// run terminates with an error instead of ping-ponging forever against a
|
|
15737
|
+
// model that deterministically emits escaped arguments. The counter
|
|
15738
|
+
// resets on any other outcome.
|
|
15739
|
+
this.#defaultFallbackChain().discardStartedAttempt();
|
|
15740
|
+
this.#consecutiveEscapedArgumentRetries++;
|
|
15741
|
+
if (this.#consecutiveEscapedArgumentRetries > MAX_ESCAPED_ARGUMENT_RETRIES) {
|
|
15742
|
+
this.#consecutiveEscapedArgumentRetries = 0;
|
|
15743
|
+
return {
|
|
15744
|
+
type: "terminal",
|
|
15745
|
+
terminal: { stopReason: "error", messages: [outcome.message] },
|
|
15746
|
+
};
|
|
15747
|
+
}
|
|
15748
|
+
return {
|
|
15749
|
+
type: "retry",
|
|
15750
|
+
continuation: async ownership => {
|
|
15751
|
+
if (!ownership.isCurrent() || ownership.lease.signal.aborted) return;
|
|
15752
|
+
await this.agent.continue(this.#managedFallbackPromptOptions());
|
|
15753
|
+
},
|
|
15754
|
+
};
|
|
15755
|
+
}
|
|
15717
15756
|
if (outcome.type === "context_overflow_discarded") {
|
|
15718
15757
|
// The provider invocation happened, but overflow is context maintenance rather
|
|
15719
15758
|
// than a fallback-policy failure. Keep the logical run owner and do not charge,
|
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
RecoveryFsIdentity,
|
|
10
10
|
RecoveryFsRoot,
|
|
11
11
|
} from "@gajae-code/natives";
|
|
12
|
+
import { isEnoent, logger } from "@gajae-code/utils";
|
|
12
13
|
import type { SessionStorageRangeSnapshot, SessionStorageStat } from "../session-storage";
|
|
13
14
|
import {
|
|
14
15
|
classifyNativePublishOutcome,
|
|
@@ -25,8 +26,10 @@ type NativeManagedSessionStorage = Pick<
|
|
|
25
26
|
| "exactReplacePath"
|
|
26
27
|
| "exactUnlink"
|
|
27
28
|
| "linkNoReplacePath"
|
|
29
|
+
| "linkNoReplacePathAsync"
|
|
28
30
|
| "openRecoveryFsRoot"
|
|
29
31
|
| "renameNoReplacePath"
|
|
32
|
+
| "renameNoReplacePathAsync"
|
|
30
33
|
| "repairOwnerOnlyPathSecurityExpected"
|
|
31
34
|
| "snapshotDirectoryTree"
|
|
32
35
|
| "verifyOwnerOnlyFdSecurity"
|
|
@@ -497,6 +500,22 @@ const SCRUBBED_REMNANT_PREFIXES = [
|
|
|
497
500
|
/** In-flight protocol steps complete in milliseconds; anything older is abandoned. */
|
|
498
501
|
const SCRUBBED_REMNANT_MIN_AGE_MS = 15 * 60 * 1000;
|
|
499
502
|
|
|
503
|
+
/** Minimum interval between best-effort remnant reaps of one bound store directory. */
|
|
504
|
+
const SCRUBBED_REMNANT_REAP_INTERVAL_MS = 60_000;
|
|
505
|
+
|
|
506
|
+
export interface ScrubbedProtocolRemnantReapResult {
|
|
507
|
+
readonly reaped: number;
|
|
508
|
+
readonly failures: number;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function reportScrubbedProtocolRemnantReap(reaped: number, failures: number): ScrubbedProtocolRemnantReapResult {
|
|
512
|
+
if (failures > 0)
|
|
513
|
+
logger.warn("Managed session remnant reaping completed with failures", {
|
|
514
|
+
failureCount: failures,
|
|
515
|
+
reapedCount: reaped,
|
|
516
|
+
});
|
|
517
|
+
return { reaped, failures };
|
|
518
|
+
}
|
|
500
519
|
/**
|
|
501
520
|
* Best-effort removal of scrubbed write-protocol remnants from one managed
|
|
502
521
|
* directory. Only zero-length, single-link, non-symlink regular files whose
|
|
@@ -535,6 +554,48 @@ export function reapScrubbedProtocolRemnantsSync(
|
|
|
535
554
|
return reaped;
|
|
536
555
|
}
|
|
537
556
|
|
|
557
|
+
/** Number of candidate remnants inspected between event-loop yields. */
|
|
558
|
+
const SCRUBBED_REMNANT_REAP_BATCH_SIZE = 256;
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Async twin of {@link reapScrubbedProtocolRemnantsSync} with the same safety
|
|
562
|
+
* filters (terminal remnant prefix, zero-length, single-link, non-symlink,
|
|
563
|
+
* older than the age gate), yielding between bounded batches. Long-lived
|
|
564
|
+
* processes reap per-session descendant directories through this path so a
|
|
565
|
+
* legacy oversized directory cannot starve timers or sibling subagents while
|
|
566
|
+
* it is being drained (issue #4394).
|
|
567
|
+
*/
|
|
568
|
+
export async function reapScrubbedProtocolRemnants(
|
|
569
|
+
directory: string,
|
|
570
|
+
minAgeMs: number = SCRUBBED_REMNANT_MIN_AGE_MS,
|
|
571
|
+
): Promise<ScrubbedProtocolRemnantReapResult> {
|
|
572
|
+
let names: string[];
|
|
573
|
+
try {
|
|
574
|
+
names = await fsp.readdir(directory);
|
|
575
|
+
} catch (error) {
|
|
576
|
+
return reportScrubbedProtocolRemnantReap(0, isEnoent(error) ? 0 : 1);
|
|
577
|
+
}
|
|
578
|
+
const cutoff = Date.now() - minAgeMs;
|
|
579
|
+
let reaped = 0;
|
|
580
|
+
let failures = 0;
|
|
581
|
+
let scanned = 0;
|
|
582
|
+
for (const name of names) {
|
|
583
|
+
if (!SCRUBBED_REMNANT_PREFIXES.some(prefix => name.startsWith(prefix))) continue;
|
|
584
|
+
if (++scanned % SCRUBBED_REMNANT_REAP_BATCH_SIZE === 0) await Bun.sleep(0);
|
|
585
|
+
const pathname = path.join(directory, name);
|
|
586
|
+
try {
|
|
587
|
+
const named = await fsp.lstat(pathname);
|
|
588
|
+
if (!named.isFile() || named.isSymbolicLink() || named.nlink !== 1 || named.size !== 0) continue;
|
|
589
|
+
if (named.mtimeMs > cutoff) continue;
|
|
590
|
+
await fsp.unlink(pathname);
|
|
591
|
+
reaped += 1;
|
|
592
|
+
} catch (error) {
|
|
593
|
+
if (!isEnoent(error)) failures += 1;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
return reportScrubbedProtocolRemnantReap(reaped, failures);
|
|
597
|
+
}
|
|
598
|
+
|
|
538
599
|
const ACL_FAILURE_CODES = new Set(["acl_denied", "acl_io_error", "acl_present", "acl_malformed", "acl_unknown"]);
|
|
539
600
|
const ACL_CLEAR_EVIDENCE = new Set(["cleared", "already_absent", "unsupported", "not_run"]);
|
|
540
601
|
const GENERAL_FAILURE_CODES = new Set([
|
|
@@ -895,6 +956,8 @@ export class ManagedSessionDescendantStore {
|
|
|
895
956
|
#ownsAuthority = false;
|
|
896
957
|
#closed = false;
|
|
897
958
|
#reconcilingReplacementCleanup = false;
|
|
959
|
+
#remnantReapInFlight = false;
|
|
960
|
+
#lastRemnantReapAttempt = 0;
|
|
898
961
|
readonly #authorityBaseDir: string;
|
|
899
962
|
/** Logical profile root inherited by nested managed session destinations. */
|
|
900
963
|
readonly #profileAgentDir: string;
|
|
@@ -1273,25 +1336,32 @@ export class ManagedSessionDescendantStore {
|
|
|
1273
1336
|
fsyncDirectory(this.#baseDir);
|
|
1274
1337
|
return;
|
|
1275
1338
|
}
|
|
1276
|
-
if (outcome.reason === "destination_exists") {
|
|
1277
|
-
const
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1339
|
+
if (outcome.reason === "destination_exists" || outcome.reason === "invalid_request") {
|
|
1340
|
+
const captureIfPresent = (pathname: string): ManagedFileSnapshot | undefined => {
|
|
1341
|
+
try {
|
|
1342
|
+
return captureManagedFilePrefixNoFollow(pathname, REPLACEMENT_CLEANUP_RECEIPT_MAX_BYTES);
|
|
1343
|
+
} catch (error) {
|
|
1344
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
1345
|
+
throw error;
|
|
1346
|
+
}
|
|
1347
|
+
};
|
|
1348
|
+
const currentPending = captureIfPresent(receiptPath);
|
|
1349
|
+
const existing = captureIfPresent(destination);
|
|
1350
|
+
if (!currentPending && existing) {
|
|
1351
|
+
if (
|
|
1352
|
+
!sameReplacementIdentity(existing.identity, pending.identity) ||
|
|
1353
|
+
existing.identity.sha256 !== pending.identity.sha256
|
|
1354
|
+
)
|
|
1355
|
+
throw new Error("managed_replace_cleanup_receipt_invalid");
|
|
1356
|
+
fsyncDirectory(this.#baseDir);
|
|
1357
|
+
return;
|
|
1358
|
+
}
|
|
1359
|
+
if (currentPending && !existing) throw publishFailure(outcome);
|
|
1360
|
+
// Two names cannot both authoritatively represent the one receipt identity
|
|
1361
|
+
// encoded in the canonical filename. A byte-identical copy has a different
|
|
1362
|
+
// filesystem identity and would make the canonical receipt fail its next
|
|
1363
|
+
// reconciliation, while a hard link violates the single-link capture fence.
|
|
1364
|
+
throw new Error("managed_replace_cleanup_receipt_invalid");
|
|
1295
1365
|
}
|
|
1296
1366
|
throw publishFailure(outcome);
|
|
1297
1367
|
}
|
|
@@ -1340,6 +1410,30 @@ export class ManagedSessionDescendantStore {
|
|
|
1340
1410
|
this.#assertBound();
|
|
1341
1411
|
this.#reconcileReplacementCleanupReceipts();
|
|
1342
1412
|
this.#assertBound();
|
|
1413
|
+
this.#scheduleRemnantReap();
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
/**
|
|
1417
|
+
* Best-effort asynchronous reaping of scrubbed write-protocol remnants in
|
|
1418
|
+
* this store's bound directory. Replacements leak zero-byte remnants on
|
|
1419
|
+
* platforms without retained authority (macOS), and scope-resolution reaping
|
|
1420
|
+
* never visits per-session descendant directories, so unbounded remnant
|
|
1421
|
+
* growth there degraded every namespace mutation and let one publication
|
|
1422
|
+
* stall the whole process (issue #4394). Reaping is throttled, serialized
|
|
1423
|
+
* per store, and never fails the triggering mutation.
|
|
1424
|
+
*/
|
|
1425
|
+
#scheduleRemnantReap(): void {
|
|
1426
|
+
const now = Date.now();
|
|
1427
|
+
if (this.#remnantReapInFlight || now - this.#lastRemnantReapAttempt < SCRUBBED_REMNANT_REAP_INTERVAL_MS) return;
|
|
1428
|
+
this.#lastRemnantReapAttempt = now;
|
|
1429
|
+
this.#remnantReapInFlight = true;
|
|
1430
|
+
void reapScrubbedProtocolRemnants(this.#baseDir)
|
|
1431
|
+
.catch((error: unknown) => {
|
|
1432
|
+
logger.warn("Managed session remnant reaping failed", { error: String(error) });
|
|
1433
|
+
})
|
|
1434
|
+
.finally(() => {
|
|
1435
|
+
this.#remnantReapInFlight = false;
|
|
1436
|
+
});
|
|
1343
1437
|
}
|
|
1344
1438
|
|
|
1345
1439
|
ensureDirectory(relativePath = ""): ManagedDirectoryRoot {
|
|
@@ -2255,6 +2349,17 @@ function fsyncDirectory(pathname: string): void {
|
|
|
2255
2349
|
}
|
|
2256
2350
|
}
|
|
2257
2351
|
|
|
2352
|
+
/** Async twin of {@link fsyncDirectory} for the off-loop publication path. */
|
|
2353
|
+
async function fsyncDirectoryAsync(pathname: string): Promise<void> {
|
|
2354
|
+
if (!shouldFsyncManagedDirectory()) return;
|
|
2355
|
+
const handle = await fsp.open(pathname, fs.constants.O_RDONLY | fs.constants.O_DIRECTORY);
|
|
2356
|
+
try {
|
|
2357
|
+
await handle.sync();
|
|
2358
|
+
} finally {
|
|
2359
|
+
await handle.close();
|
|
2360
|
+
}
|
|
2361
|
+
}
|
|
2362
|
+
|
|
2258
2363
|
function bootId(): string | undefined {
|
|
2259
2364
|
try {
|
|
2260
2365
|
return fs.readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim();
|
|
@@ -2536,7 +2641,7 @@ export async function publishManagedFileNoReplace(
|
|
|
2536
2641
|
const parent = path.dirname(destination);
|
|
2537
2642
|
ensureManagedDirectory(parent, root, policy);
|
|
2538
2643
|
const staging = path.join(parent, `.${path.basename(destination)}.${randomUUID()}.staging`);
|
|
2539
|
-
let
|
|
2644
|
+
let handle: fsp.FileHandle | undefined;
|
|
2540
2645
|
let stagingIdentity: { dev: bigint; ino: bigint } | undefined;
|
|
2541
2646
|
let failure: unknown;
|
|
2542
2647
|
let outcome: NativePublishOutcome | undefined;
|
|
@@ -2545,36 +2650,47 @@ export async function publishManagedFileNoReplace(
|
|
|
2545
2650
|
|
|
2546
2651
|
try {
|
|
2547
2652
|
assertOwned?.();
|
|
2548
|
-
|
|
2653
|
+
// The whole staging + publication chain is awaited off the resident event
|
|
2654
|
+
// loop: FileHandle operations run on the libuv pool and the no-replace
|
|
2655
|
+
// namespace publication crosses the async native boundary. A rename that
|
|
2656
|
+
// stalls in the kernel — e.g. into an oversized APFS directory namespace,
|
|
2657
|
+
// issue #4394 — blocks one pool thread instead of freezing the process:
|
|
2658
|
+
// await timeouts, sibling subagents, and watchdogs keep running, and a
|
|
2659
|
+
// hung publication degrades to one unresolved receipt.
|
|
2660
|
+
handle = await fsp.open(
|
|
2549
2661
|
staging,
|
|
2550
2662
|
fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY | fs.constants.O_NOFOLLOW,
|
|
2551
2663
|
0o600,
|
|
2552
2664
|
);
|
|
2553
|
-
secureFileDescriptor(staging, fd, "apply");
|
|
2665
|
+
secureFileDescriptor(staging, handle.fd, "apply");
|
|
2554
2666
|
let offset = 0;
|
|
2555
|
-
while (offset < bytes.byteLength)
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
2667
|
+
while (offset < bytes.byteLength) {
|
|
2668
|
+
const { bytesWritten } = await handle.write(bytes, offset, bytes.byteLength - offset);
|
|
2669
|
+
if (bytesWritten <= 0) throw new Error("managed_publish_failed");
|
|
2670
|
+
offset += bytesWritten;
|
|
2671
|
+
}
|
|
2672
|
+
await handle.sync();
|
|
2673
|
+
secureFileDescriptor(staging, handle.fd, "verify");
|
|
2674
|
+
const staged = await handle.stat({ bigint: true });
|
|
2559
2675
|
stagingIdentity = { dev: staged.dev, ino: staged.ino };
|
|
2560
2676
|
assertOwned?.();
|
|
2561
2677
|
|
|
2562
2678
|
renameAttempted = true;
|
|
2563
|
-
outcome = classifyNativePublishOutcome(
|
|
2679
|
+
outcome = classifyNativePublishOutcome(
|
|
2680
|
+
await nativeSessionStorage().renameNoReplacePathAsync(staging, destination),
|
|
2681
|
+
);
|
|
2564
2682
|
if (renameFlagsUnsupported(outcome)) {
|
|
2565
|
-
//
|
|
2566
|
-
//
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
// the published inode.
|
|
2571
|
-
outcome = classifyNativePublishOutcome(nativeSessionStorage().linkNoReplacePath(staging, destination));
|
|
2683
|
+
// See publishManagedFileNoReplaceSync: the staging link outlives this
|
|
2684
|
+
// publication and is removed only after the secured descriptor is closed.
|
|
2685
|
+
outcome = classifyNativePublishOutcome(
|
|
2686
|
+
await nativeSessionStorage().linkNoReplacePathAsync(staging, destination),
|
|
2687
|
+
);
|
|
2572
2688
|
linkPublished = outcome.ok;
|
|
2573
2689
|
}
|
|
2574
2690
|
|
|
2575
2691
|
if (!outcome.ok) throw publishFailure(outcome);
|
|
2576
2692
|
|
|
2577
|
-
const named =
|
|
2693
|
+
const named = await fsp.lstat(destination, { bigint: true });
|
|
2578
2694
|
if (
|
|
2579
2695
|
!named.isFile() ||
|
|
2580
2696
|
named.isSymbolicLink() ||
|
|
@@ -2583,15 +2699,21 @@ export async function publishManagedFileNoReplace(
|
|
|
2583
2699
|
) {
|
|
2584
2700
|
throw new Error("destination_identity_changed");
|
|
2585
2701
|
}
|
|
2586
|
-
secureFileDescriptor(destination, fd, "verify");
|
|
2587
|
-
|
|
2588
|
-
|
|
2702
|
+
secureFileDescriptor(destination, handle.fd, "verify");
|
|
2703
|
+
await handle.close();
|
|
2704
|
+
handle = undefined;
|
|
2589
2705
|
|
|
2590
|
-
|
|
2706
|
+
await fsyncDirectoryAsync(parent);
|
|
2591
2707
|
} catch (error) {
|
|
2592
2708
|
failure = error;
|
|
2593
2709
|
} finally {
|
|
2594
|
-
if (
|
|
2710
|
+
if (handle !== undefined) {
|
|
2711
|
+
try {
|
|
2712
|
+
await handle.close();
|
|
2713
|
+
} catch (error) {
|
|
2714
|
+
failure ??= error;
|
|
2715
|
+
}
|
|
2716
|
+
}
|
|
2595
2717
|
|
|
2596
2718
|
if (stagingIdentity && (linkPublished || !renameAttempted || (outcome && mayCleanCurrentStaging(outcome)))) {
|
|
2597
2719
|
await fsp
|