@gajae-code/coding-agent 0.11.4 → 0.11.5
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 +23 -0
- package/dist/types/cli/notify-cli.d.ts +5 -1
- package/dist/types/config/settings-schema.d.ts +20 -0
- package/dist/types/export/html/template.generated.d.ts +1 -1
- package/dist/types/gjc-runtime/deep-interview-state.d.ts +28 -0
- package/dist/types/gjc-runtime/launch-tmux.d.ts +3 -0
- package/dist/types/gjc-runtime/managed-owner-admission.d.ts +42 -0
- package/dist/types/gjc-runtime/managed-owner-supervisor.d.ts +41 -0
- package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +9 -0
- package/dist/types/gjc-runtime/ultragoal-owner-loss-recovery.d.ts +94 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +1 -0
- package/dist/types/harness-control-plane/owner.d.ts +3 -1
- package/dist/types/internal-urls/local-protocol.d.ts +18 -0
- package/dist/types/main.d.ts +2 -2
- package/dist/types/modes/components/ansi-display-validator.d.ts +2 -0
- package/dist/types/modes/components/notifications-settings-editor.d.ts +4 -0
- package/dist/types/modes/components/session-observer-overlay.d.ts +3 -1
- package/dist/types/modes/components/settings-selector.d.ts +2 -0
- package/dist/types/modes/components/tool-transcript-format.d.ts +58 -0
- package/dist/types/modes/components/transcript-viewer-overlay.d.ts +3 -0
- package/dist/types/modes/controllers/goal-mode-controller.d.ts +8 -1
- package/dist/types/modes/controllers/selector-controller.d.ts +15 -0
- package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +3 -0
- package/dist/types/modes/shared/agent-wire/workflow-gate-types.d.ts +16 -0
- package/dist/types/plan-mode/approved-plan.d.ts +1 -0
- package/dist/types/runtime-mcp/manager.d.ts +2 -0
- package/dist/types/runtime-mcp/oauth-discovery.d.ts +12 -1
- package/dist/types/sdk/bus/chat-daemon-cli.d.ts +3 -1
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +39 -4
- package/dist/types/sdk/bus/config-commands.d.ts +8 -0
- package/dist/types/sdk/bus/config.d.ts +22 -0
- package/dist/types/sdk/bus/index.d.ts +162 -2
- package/dist/types/sdk/bus/notification-service.d.ts +71 -8
- package/dist/types/sdk/bus/session-control.d.ts +11 -8
- package/dist/types/sdk/bus/telegram-daemon-cli.d.ts +5 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +6 -3
- package/dist/types/sdk/bus/telegram-daemon-control.d.ts +23 -4
- package/dist/types/sdk/bus/telegram-daemon.d.ts +214 -20
- package/dist/types/sdk/session.d.ts +16 -1
- package/dist/types/secrets/index.d.ts +4 -2
- package/dist/types/secrets/obfuscator.d.ts +1 -1
- package/dist/types/session/agent-session.d.ts +31 -2
- package/dist/types/session/artifacts.d.ts +30 -8
- package/dist/types/session/internal/managed-session-scope.d.ts +7 -0
- package/dist/types/session/internal/managed-session-storage.d.ts +63 -6
- package/dist/types/session/session-manager.d.ts +88 -18
- package/dist/types/session/session-storage.d.ts +27 -0
- package/dist/types/session/streaming-output.d.ts +4 -0
- package/dist/types/task/executor.d.ts +9 -0
- package/dist/types/tool-discovery/tool-index.d.ts +2 -0
- package/dist/types/tools/ask.d.ts +48 -0
- package/dist/types/tools/bash-skill-urls.d.ts +2 -2
- package/dist/types/tools/index.d.ts +4 -0
- package/package.json +9 -9
- package/scripts/generate-sdk-operation-inventory.ts +2 -0
- package/src/cli/notify-cli.ts +40 -18
- package/src/commands/sdk.ts +7 -2
- package/src/config/settings-schema.ts +17 -0
- package/src/daemon/runtime.ts +2 -2
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +70 -49
- package/src/defaults/gjc/skills/ralplan/SKILL.md +0 -6
- package/src/defaults/gjc/skills/team/SKILL.md +7 -9
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +0 -2
- package/src/eval/py/index.ts +2 -2
- package/src/export/html/template.generated.ts +1 -1
- package/src/extensibility/plugins/marketplace/manager.ts +94 -10
- package/src/gjc-runtime/deep-interview-recorder.ts +7 -0
- package/src/gjc-runtime/deep-interview-runtime.ts +10 -0
- package/src/gjc-runtime/deep-interview-state.ts +127 -3
- package/src/gjc-runtime/launch-tmux.ts +61 -6
- package/src/gjc-runtime/managed-owner-admission.ts +352 -0
- package/src/gjc-runtime/managed-owner-supervisor.ts +162 -0
- package/src/gjc-runtime/state-runtime.ts +38 -12
- package/src/gjc-runtime/team-runtime.ts +1 -1
- package/src/gjc-runtime/tmux-owner-isolation.ts +117 -0
- package/src/gjc-runtime/ultragoal-owner-loss-recovery.ts +328 -0
- package/src/gjc-runtime/ultragoal-runtime.ts +14 -0
- package/src/harness-control-plane/owner.ts +59 -3
- package/src/hindsight/client.ts +65 -2
- package/src/internal-urls/agent-protocol.ts +76 -5
- package/src/internal-urls/docs-index.generated.ts +7 -6
- package/src/internal-urls/local-protocol.ts +416 -18
- package/src/lsp/index.ts +4 -88
- package/src/main.ts +37 -13
- package/src/modes/acp/acp-agent.ts +8 -0
- package/src/modes/components/ansi-display-validator.ts +54 -0
- package/src/modes/components/notifications-settings-editor.ts +26 -2
- package/src/modes/components/session-observer-overlay.ts +40 -24
- package/src/modes/components/settings-selector.ts +3 -1
- package/src/modes/components/tool-transcript-format.ts +321 -0
- package/src/modes/components/transcript-viewer-overlay.ts +39 -17
- package/src/modes/controllers/goal-mode-controller.ts +20 -1
- package/src/modes/controllers/plan-mode-controller.ts +2 -0
- package/src/modes/controllers/runtime-mcp-command-controller.ts +3 -0
- package/src/modes/controllers/selector-controller.ts +70 -8
- package/src/modes/interactive-mode.ts +1 -0
- package/src/modes/shared/agent-wire/deep-interview-gate.ts +31 -1
- package/src/modes/shared/agent-wire/workflow-gate-broker.ts +17 -1
- package/src/modes/shared/agent-wire/workflow-gate-schema.ts +4 -2
- package/src/modes/shared/agent-wire/workflow-gate-types.ts +120 -3
- package/src/plan-mode/approved-plan.ts +3 -1
- package/src/prompts/agents/architect.md +1 -0
- package/src/prompts/agents/critic.md +1 -0
- package/src/prompts/system/custom-system-prompt.md +1 -1
- package/src/prompts/system/system-prompt.md +2 -1
- package/src/prompts/tools/lsp.md +3 -3
- package/src/runtime-mcp/manager.ts +16 -1
- package/src/runtime-mcp/oauth-discovery.ts +116 -26
- package/src/sdk/broker/session-index.ts +122 -64
- package/src/sdk/bus/chat-daemon-cli.ts +72 -10
- package/src/sdk/bus/chat-daemon-control.ts +526 -130
- package/src/sdk/bus/chat-daemon-runtime.ts +1 -0
- package/src/sdk/bus/config-commands.ts +22 -0
- package/src/sdk/bus/config.ts +37 -0
- package/src/sdk/bus/index.ts +384 -92
- package/src/sdk/bus/lifecycle-control-runtime.ts +53 -3
- package/src/sdk/bus/notification-service.ts +284 -62
- package/src/sdk/bus/recent-activity.ts +14 -5
- package/src/sdk/bus/session-control.ts +150 -44
- package/src/sdk/bus/telegram-daemon-cli.ts +20 -14
- package/src/sdk/bus/telegram-daemon-contract.ts +6 -3
- package/src/sdk/bus/telegram-daemon-control.ts +294 -76
- package/src/sdk/bus/telegram-daemon.ts +1788 -267
- package/src/sdk/protocol/operation-inventory.generated.json +11 -0
- package/src/sdk/session.ts +155 -27
- package/src/secrets/index.ts +53 -10
- package/src/secrets/obfuscator.ts +21 -18
- package/src/secrets/regex.ts +2 -0
- package/src/session/agent-session.ts +495 -253
- package/src/session/artifacts.ts +188 -23
- package/src/session/context-estimation.ts +7 -2
- package/src/session/internal/managed-session-scope.ts +200 -38
- package/src/session/internal/managed-session-storage.ts +1152 -46
- package/src/session/session-manager.ts +1055 -148
- package/src/session/session-storage.ts +109 -11
- package/src/session/streaming-output.ts +6 -0
- package/src/task/executor.ts +71 -26
- package/src/task/index.ts +63 -20
- package/src/tool-discovery/tool-index.ts +18 -0
- package/src/tools/ask.ts +129 -32
- package/src/tools/bash-skill-urls.ts +14 -3
- package/src/tools/bash.ts +2 -2
- package/src/tools/index.ts +4 -0
- package/src/tools/plan-mode-guard.ts +1 -0
- package/src/tools/sqlite-reader.ts +3 -2
- package/src/tools/subagent.ts +2 -0
|
@@ -13,6 +13,18 @@ import * as path from "node:path";
|
|
|
13
13
|
import type { NotificationControlServer as NativeNotificationControlServer } from "@gajae-code/natives";
|
|
14
14
|
import { logger } from "@gajae-code/utils";
|
|
15
15
|
import { readLinuxProcStartTime } from "../../gjc-runtime/linux-proc";
|
|
16
|
+
import {
|
|
17
|
+
MANAGED_OWNER_PREDECESSOR_GENERATION_ENV,
|
|
18
|
+
MANAGED_OWNER_PREDECESSOR_INCARNATION_ENV,
|
|
19
|
+
MANAGED_OWNER_PREDECESSOR_RUN_ID_ENV,
|
|
20
|
+
MANAGED_OWNER_PREDECESSOR_TOKEN_ENV,
|
|
21
|
+
MANAGED_OWNER_TRANSCRIPT_PATH_ENV,
|
|
22
|
+
} from "../../gjc-runtime/managed-owner-admission";
|
|
23
|
+
import {
|
|
24
|
+
MANAGED_OWNER_INCARNATION_ENV,
|
|
25
|
+
MANAGED_OWNER_RUN_ID_ENV,
|
|
26
|
+
MANAGED_OWNER_SUPERVISOR_ARG,
|
|
27
|
+
} from "../../gjc-runtime/managed-owner-supervisor";
|
|
16
28
|
import { tmuxRuntimeSessionPath } from "../../gjc-runtime/session-layout";
|
|
17
29
|
import {
|
|
18
30
|
GJC_COORDINATOR_SESSION_ID_ENV,
|
|
@@ -35,6 +47,7 @@ import {
|
|
|
35
47
|
type OwnerIsolationProbe,
|
|
36
48
|
planTmuxOwnerIsolation,
|
|
37
49
|
replaceOwnerGeneration,
|
|
50
|
+
resolveManagedOwnerPredecessorSync,
|
|
38
51
|
type TmuxServerProof,
|
|
39
52
|
} from "../../gjc-runtime/tmux-owner-isolation";
|
|
40
53
|
import {
|
|
@@ -768,8 +781,10 @@ async function completeLifecycleSpawnTransaction(input: {
|
|
|
768
781
|
argv: string[];
|
|
769
782
|
ownerIsolationProbe?: OwnerIsolationProbe;
|
|
770
783
|
prepareSpawn?: () => void;
|
|
784
|
+
previousBaseline?: OwnerGenerationBaseline;
|
|
771
785
|
}): Promise<void> {
|
|
772
|
-
const previousBaseline =
|
|
786
|
+
const previousBaseline =
|
|
787
|
+
input.previousBaseline ?? (await captureOwnerGenerationBaseline(input.stateDir, input.sessionId));
|
|
773
788
|
let ownerExecution: LifecycleAttemptExecution | undefined;
|
|
774
789
|
try {
|
|
775
790
|
ownerExecution = await executeLifecycleTmuxOwnerPlan({
|
|
@@ -817,6 +832,7 @@ async function completeLifecycleSpawnTransaction(input: {
|
|
|
817
832
|
throw new Error("gjc_lifecycle_owner_server_unverifiable");
|
|
818
833
|
if (!(await isLifecycleGenerationUnchanged(input.stateDir, input.sessionId, previousBaseline)))
|
|
819
834
|
throw new Error("gjc_lifecycle_owner_generation_changed");
|
|
835
|
+
resolveManagedOwnerPredecessorSync(input.stateDir, input.sessionId, previousBaseline);
|
|
820
836
|
await replaceOwnerGeneration(input.stateDir, input.sessionId, input.generation, previousBaseline);
|
|
821
837
|
} catch (error) {
|
|
822
838
|
let cleanupFailure: unknown;
|
|
@@ -857,7 +873,11 @@ export function daemonSpawnCreate(
|
|
|
857
873
|
const { cwd, args } = buildCreateArgv(frame, ids);
|
|
858
874
|
const sessionStateFile = lifecycleRuntimeStateFile(cwd, ids.intendedSessionId, name);
|
|
859
875
|
const stateDir = path.dirname(sessionStateFile);
|
|
876
|
+
const previousBaseline = await captureOwnerGenerationBaseline(stateDir, ids.intendedSessionId);
|
|
877
|
+
const predecessor = resolveManagedOwnerPredecessorSync(stateDir, ids.intendedSessionId, previousBaseline);
|
|
860
878
|
const generation = crypto.randomUUID();
|
|
879
|
+
const runId = crypto.randomUUID();
|
|
880
|
+
const incarnation = crypto.randomUUID();
|
|
861
881
|
// Detached: no interactive TTY needed (daemon-safe). These values contain
|
|
862
882
|
// only opaque ids and paths needed by the resident sidecar to publish its
|
|
863
883
|
// exact-owner terminal verdict.
|
|
@@ -871,12 +891,24 @@ export function daemonSpawnCreate(
|
|
|
871
891
|
[GJC_TMUX_OWNER_GENERATION_ENV]: generation,
|
|
872
892
|
[GJC_TMUX_OWNER_STATE_DIR_ENV]: stateDir,
|
|
873
893
|
[GJC_TMUX_OWNER_SERVER_KEY_ENV]: "default",
|
|
894
|
+
GJC_MANAGED_OWNER_COMMAND_JSON: JSON.stringify(["gjc", ...args]),
|
|
895
|
+
[MANAGED_OWNER_RUN_ID_ENV]: runId,
|
|
896
|
+
[MANAGED_OWNER_INCARNATION_ENV]: incarnation,
|
|
897
|
+
...(predecessor
|
|
898
|
+
? {
|
|
899
|
+
[MANAGED_OWNER_PREDECESSOR_TOKEN_ENV]: predecessor.predecessorToken,
|
|
900
|
+
[MANAGED_OWNER_PREDECESSOR_GENERATION_ENV]: predecessor.generation,
|
|
901
|
+
[MANAGED_OWNER_PREDECESSOR_RUN_ID_ENV]: predecessor.runId,
|
|
902
|
+
[MANAGED_OWNER_PREDECESSOR_INCARNATION_ENV]: predecessor.incarnation,
|
|
903
|
+
[MANAGED_OWNER_TRANSCRIPT_PATH_ENV]: env.GJC_SESSION_FILE ?? "",
|
|
904
|
+
}
|
|
905
|
+
: {}),
|
|
874
906
|
};
|
|
875
907
|
if (ids.startupPromptRef) childEnv.GJC_STARTUP_PROMPT_REF = ids.startupPromptRef;
|
|
876
908
|
const envPairs = Object.entries(childEnv)
|
|
877
909
|
.map(([key, value]) => `${key}=${shellQuote(value)}`)
|
|
878
910
|
.join(" ");
|
|
879
|
-
const command = `cd ${shellQuote(cwd)} && exec env ${envPairs} gjc ${
|
|
911
|
+
const command = `cd ${shellQuote(cwd)} && exec env ${envPairs} gjc ${shellQuote(MANAGED_OWNER_SUPERVISOR_ARG)}`;
|
|
880
912
|
await completeLifecycleSpawnTransaction({
|
|
881
913
|
tmux,
|
|
882
914
|
env,
|
|
@@ -891,6 +923,7 @@ export function daemonSpawnCreate(
|
|
|
891
923
|
prepareSpawn: () => {
|
|
892
924
|
if (frame.target.kind === "plain_dir") fs.mkdirSync(cwd, { recursive: true });
|
|
893
925
|
},
|
|
926
|
+
previousBaseline,
|
|
894
927
|
});
|
|
895
928
|
|
|
896
929
|
return {
|
|
@@ -1053,7 +1086,11 @@ export function daemonResumeSession(
|
|
|
1053
1086
|
const name = tmuxSessionNameFor(resumeId);
|
|
1054
1087
|
const sessionStateFile = lifecycleRuntimeStateFile(resolvedResumeCwd, resumeId, name);
|
|
1055
1088
|
const stateDir = path.dirname(sessionStateFile);
|
|
1089
|
+
const previousBaseline = await captureOwnerGenerationBaseline(stateDir, resumeId);
|
|
1090
|
+
const predecessor = resolveManagedOwnerPredecessorSync(stateDir, resumeId, previousBaseline);
|
|
1056
1091
|
const generation = crypto.randomUUID();
|
|
1092
|
+
const runId = crypto.randomUUID();
|
|
1093
|
+
const incarnation = crypto.randomUUID();
|
|
1057
1094
|
const childEnv: Record<string, string> = {
|
|
1058
1095
|
GJC_TMUX_LAUNCHED: "1",
|
|
1059
1096
|
GJC_NOTIFICATIONS: "1",
|
|
@@ -1062,11 +1099,23 @@ export function daemonResumeSession(
|
|
|
1062
1099
|
[GJC_TMUX_OWNER_GENERATION_ENV]: generation,
|
|
1063
1100
|
[GJC_TMUX_OWNER_STATE_DIR_ENV]: stateDir,
|
|
1064
1101
|
[GJC_TMUX_OWNER_SERVER_KEY_ENV]: "default",
|
|
1102
|
+
GJC_MANAGED_OWNER_COMMAND_JSON: JSON.stringify(["gjc", "--resume", resumeId]),
|
|
1103
|
+
[MANAGED_OWNER_RUN_ID_ENV]: runId,
|
|
1104
|
+
[MANAGED_OWNER_INCARNATION_ENV]: incarnation,
|
|
1105
|
+
...(predecessor
|
|
1106
|
+
? {
|
|
1107
|
+
[MANAGED_OWNER_PREDECESSOR_TOKEN_ENV]: predecessor.predecessorToken,
|
|
1108
|
+
[MANAGED_OWNER_PREDECESSOR_GENERATION_ENV]: predecessor.generation,
|
|
1109
|
+
[MANAGED_OWNER_PREDECESSOR_RUN_ID_ENV]: predecessor.runId,
|
|
1110
|
+
[MANAGED_OWNER_PREDECESSOR_INCARNATION_ENV]: predecessor.incarnation,
|
|
1111
|
+
[MANAGED_OWNER_TRANSCRIPT_PATH_ENV]: env.GJC_SESSION_FILE ?? "",
|
|
1112
|
+
}
|
|
1113
|
+
: {}),
|
|
1065
1114
|
};
|
|
1066
1115
|
const envPairs = Object.entries(childEnv)
|
|
1067
1116
|
.map(([key, value]) => `${key}=${shellQuote(value)}`)
|
|
1068
1117
|
.join(" ");
|
|
1069
|
-
const command = `cd ${shellQuote(resolvedResumeCwd)} && exec env ${envPairs} gjc
|
|
1118
|
+
const command = `cd ${shellQuote(resolvedResumeCwd)} && exec env ${envPairs} gjc ${shellQuote(MANAGED_OWNER_SUPERVISOR_ARG)}`;
|
|
1070
1119
|
await completeLifecycleSpawnTransaction({
|
|
1071
1120
|
tmux,
|
|
1072
1121
|
env,
|
|
@@ -1078,6 +1127,7 @@ export function daemonResumeSession(
|
|
|
1078
1127
|
sessionStateFile,
|
|
1079
1128
|
argv: [tmux, "new-session", "-d", "-P", "-F", "#{session_id}", "-s", name, "sh", "-c", command],
|
|
1080
1129
|
ownerIsolationProbe: opts.ownerIsolationProbe,
|
|
1130
|
+
previousBaseline,
|
|
1081
1131
|
});
|
|
1082
1132
|
|
|
1083
1133
|
return {
|
|
@@ -11,11 +11,13 @@
|
|
|
11
11
|
* touches a live owner's lock/state and never kills a process.
|
|
12
12
|
*/
|
|
13
13
|
import * as crypto from "node:crypto";
|
|
14
|
+
import type { WriteFileOptions } from "node:fs";
|
|
14
15
|
import * as fsSync from "node:fs";
|
|
15
16
|
import * as fsPromises from "node:fs/promises";
|
|
16
17
|
import * as path from "node:path";
|
|
17
18
|
import * as native from "@gajae-code/natives";
|
|
18
19
|
import type { Settings } from "../../config/settings";
|
|
20
|
+
import { isProcessIncarnation, processIncarnation } from "../broker/process-incarnation";
|
|
19
21
|
import {
|
|
20
22
|
getNotificationConfig,
|
|
21
23
|
isDiscordConfigured,
|
|
@@ -67,6 +69,66 @@ export interface NotificationExactUnlinkResult {
|
|
|
67
69
|
ok: boolean;
|
|
68
70
|
code?: string;
|
|
69
71
|
detachedPath?: string;
|
|
72
|
+
/** A live publisher successor retained after an exact-unlink race. */
|
|
73
|
+
retainedSuccessorPath?: string;
|
|
74
|
+
/** An internal exchange placeholder whose verified cleanup failed. */
|
|
75
|
+
retainedPlaceholderPath?: string;
|
|
76
|
+
/** A cleanup entry whose identity could not be verified after a race. */
|
|
77
|
+
retainedUnknownPath?: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Read one regular file together with the identity required for exact removal. */
|
|
81
|
+
export async function readNotificationEndpointFile(file: string): Promise<NotificationEndpointFile> {
|
|
82
|
+
const before = await fsPromises.lstat(file, { bigint: true });
|
|
83
|
+
if (!before.isFile() || before.isSymbolicLink()) throw new Error("Endpoint is not a regular file");
|
|
84
|
+
const noFollow = fsSync.constants.O_NOFOLLOW;
|
|
85
|
+
const handle = await fsPromises.open(file, fsSync.constants.O_RDONLY | (noFollow ?? 0));
|
|
86
|
+
try {
|
|
87
|
+
const opened = await handle.stat({ bigint: true });
|
|
88
|
+
if (!opened.isFile() || !sameEndpointFileMetadata(before, opened))
|
|
89
|
+
throw new Error("Endpoint changed before it was opened");
|
|
90
|
+
const bytes = await handle.readFile();
|
|
91
|
+
const after = await handle.stat({ bigint: true });
|
|
92
|
+
const pathname = await fsPromises.lstat(file, { bigint: true });
|
|
93
|
+
if (
|
|
94
|
+
!after.isFile() ||
|
|
95
|
+
!pathname.isFile() ||
|
|
96
|
+
pathname.isSymbolicLink() ||
|
|
97
|
+
!sameEndpointFileMetadata(opened, after) ||
|
|
98
|
+
!sameEndpointFileMetadata(opened, pathname)
|
|
99
|
+
)
|
|
100
|
+
throw new Error("Endpoint changed while it was read");
|
|
101
|
+
return {
|
|
102
|
+
bytes,
|
|
103
|
+
identity: {
|
|
104
|
+
dev: opened.dev,
|
|
105
|
+
ino: opened.ino,
|
|
106
|
+
size: opened.size,
|
|
107
|
+
mtimeNs: opened.mtimeNs,
|
|
108
|
+
sha256: crypto.createHash("sha256").update(bytes).digest("hex"),
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
} finally {
|
|
112
|
+
await handle.close();
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/** Bump when the native exact-deletion identity contract changes. */
|
|
116
|
+
export const NATIVE_PATH_IDENTITY_CONTRACT_VERSION = 1;
|
|
117
|
+
|
|
118
|
+
export function exactUnlinkNotificationFile(
|
|
119
|
+
file: string,
|
|
120
|
+
identity: NotificationEndpointFileIdentity,
|
|
121
|
+
quarantineName: string,
|
|
122
|
+
): NotificationExactUnlinkResult {
|
|
123
|
+
const result = native.exactUnlink(file, { ...identity, quarantineName });
|
|
124
|
+
return {
|
|
125
|
+
ok: result.ok,
|
|
126
|
+
code: result.code,
|
|
127
|
+
detachedPath: result.detachedPath,
|
|
128
|
+
retainedSuccessorPath: result.retainedSuccessorPath,
|
|
129
|
+
retainedPlaceholderPath: result.retainedPlaceholderPath,
|
|
130
|
+
retainedUnknownPath: result.retainedUnknownPath,
|
|
131
|
+
};
|
|
70
132
|
}
|
|
71
133
|
|
|
72
134
|
/** Minimal filesystem surface the service needs; injectable for tests. */
|
|
@@ -76,69 +138,19 @@ export interface NotificationServiceFs {
|
|
|
76
138
|
readEndpointFile(file: string): Promise<NotificationEndpointFile>;
|
|
77
139
|
exactUnlink(file: string, identity: NotificationEndpointFileIdentity): Promise<NotificationExactUnlinkResult>;
|
|
78
140
|
unlink(file: string): Promise<void>;
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
* call created it, `false` when it already existed. Used to hold the daemon
|
|
82
|
-
* steal-mutex ({@link DaemonPaths.steal}) during owner-bound lock removal so
|
|
83
|
-
* recovery and a concurrent daemon takeover are mutually exclusive.
|
|
84
|
-
*/
|
|
85
|
-
createExclusive(file: string): Promise<boolean>;
|
|
141
|
+
writeFile?(file: string, data: string, opts?: WriteFileOptions): Promise<void>;
|
|
142
|
+
stat?(file: string): Promise<{ mtimeMs: number }>;
|
|
86
143
|
}
|
|
87
144
|
|
|
88
145
|
const nodeServiceFs: NotificationServiceFs = {
|
|
89
146
|
readdir: dir => fsPromises.readdir(dir),
|
|
90
147
|
readFile: (file, encoding) => fsPromises.readFile(file, encoding),
|
|
91
|
-
readEndpointFile:
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const noFollow = fsSync.constants.O_NOFOLLOW;
|
|
95
|
-
const handle = await fsPromises.open(file, fsSync.constants.O_RDONLY | (noFollow ?? 0));
|
|
96
|
-
try {
|
|
97
|
-
const opened = await handle.stat({ bigint: true });
|
|
98
|
-
if (!opened.isFile() || !sameEndpointFileMetadata(before, opened))
|
|
99
|
-
throw new Error("Endpoint changed before it was opened");
|
|
100
|
-
const bytes = await handle.readFile();
|
|
101
|
-
const after = await handle.stat({ bigint: true });
|
|
102
|
-
const pathname = await fsPromises.lstat(file, { bigint: true });
|
|
103
|
-
if (
|
|
104
|
-
!after.isFile() ||
|
|
105
|
-
!pathname.isFile() ||
|
|
106
|
-
pathname.isSymbolicLink() ||
|
|
107
|
-
!sameEndpointFileMetadata(opened, after) ||
|
|
108
|
-
!sameEndpointFileMetadata(opened, pathname)
|
|
109
|
-
)
|
|
110
|
-
throw new Error("Endpoint changed while it was read");
|
|
111
|
-
return {
|
|
112
|
-
bytes,
|
|
113
|
-
identity: {
|
|
114
|
-
dev: opened.dev,
|
|
115
|
-
ino: opened.ino,
|
|
116
|
-
size: opened.size,
|
|
117
|
-
mtimeNs: opened.mtimeNs,
|
|
118
|
-
sha256: crypto.createHash("sha256").update(bytes).digest("hex"),
|
|
119
|
-
},
|
|
120
|
-
};
|
|
121
|
-
} finally {
|
|
122
|
-
await handle.close();
|
|
123
|
-
}
|
|
124
|
-
},
|
|
125
|
-
exactUnlink: async (file, identity) => {
|
|
126
|
-
const result = native.exactUnlink(file, {
|
|
127
|
-
...identity,
|
|
128
|
-
quarantineName: `.gjc-delete-notification-endpoint-${crypto.randomUUID()}.json`,
|
|
129
|
-
});
|
|
130
|
-
return { ok: result.ok, code: result.code, detachedPath: result.detachedPath };
|
|
131
|
-
},
|
|
148
|
+
readEndpointFile: readNotificationEndpointFile,
|
|
149
|
+
exactUnlink: async (file, identity) =>
|
|
150
|
+
exactUnlinkNotificationFile(file, identity, `.gjc-delete-notification-endpoint-${crypto.randomUUID()}.json`),
|
|
132
151
|
unlink: file => fsPromises.unlink(file),
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
const handle = await fsPromises.open(file, "wx", 0o600);
|
|
136
|
-
await handle.close();
|
|
137
|
-
return true;
|
|
138
|
-
} catch {
|
|
139
|
-
return false;
|
|
140
|
-
}
|
|
141
|
-
},
|
|
152
|
+
writeFile: (file, data, opts) => fsPromises.writeFile(file, data, opts),
|
|
153
|
+
stat: file => fsPromises.stat(file),
|
|
142
154
|
};
|
|
143
155
|
|
|
144
156
|
/** Injectable dependencies shared across service operations. */
|
|
@@ -766,6 +778,12 @@ export interface NotificationRecoveryReport {
|
|
|
766
778
|
endpointsKept: number;
|
|
767
779
|
endpointsUnreadable: number;
|
|
768
780
|
endpointsDetached?: string[];
|
|
781
|
+
/** Successor paths retained after an exact-unlink race, distinct from stale quarantines. */
|
|
782
|
+
endpointsRetainedSuccessors?: string[];
|
|
783
|
+
/** Internal exchange placeholders retained after verified cleanup failure. */
|
|
784
|
+
endpointsRetainedPlaceholders?: string[];
|
|
785
|
+
/** Cleanup entries retained with unverified or mismatching identity. */
|
|
786
|
+
endpointsRetainedUnknown?: string[];
|
|
769
787
|
daemon: {
|
|
770
788
|
action: DaemonRecoveryAction;
|
|
771
789
|
detail: string;
|
|
@@ -780,6 +798,179 @@ export interface RecoveryOptions {
|
|
|
780
798
|
deps?: NotificationServiceDeps;
|
|
781
799
|
}
|
|
782
800
|
|
|
801
|
+
export interface DaemonTransitionLock {
|
|
802
|
+
pid: number;
|
|
803
|
+
incarnation: string;
|
|
804
|
+
createdAt: number;
|
|
805
|
+
/** Unique fencing generation for this particular transition acquisition. */
|
|
806
|
+
token: string;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
function isDaemonTransitionLock(value: unknown): value is DaemonTransitionLock {
|
|
810
|
+
if (!value || typeof value !== "object") return false;
|
|
811
|
+
const candidate = value as Record<string, unknown>;
|
|
812
|
+
return (
|
|
813
|
+
Number.isSafeInteger(candidate.pid) &&
|
|
814
|
+
(candidate.pid as number) > 0 &&
|
|
815
|
+
typeof candidate.incarnation === "string" &&
|
|
816
|
+
typeof candidate.createdAt === "number" &&
|
|
817
|
+
typeof candidate.token === "string" &&
|
|
818
|
+
candidate.token.length > 0
|
|
819
|
+
);
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
interface TransitionMarkerSnapshot {
|
|
823
|
+
raw: string;
|
|
824
|
+
identity?: NotificationEndpointFileIdentity;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
type TransitionMarkerFs = {
|
|
828
|
+
readFile(file: string, encoding: "utf8"): Promise<string>;
|
|
829
|
+
writeFile?(file: string, data: string, opts?: WriteFileOptions): Promise<void>;
|
|
830
|
+
readEndpointFile?(file: string): Promise<NotificationEndpointFile>;
|
|
831
|
+
exactUnlink?(file: string, identity: NotificationEndpointFileIdentity): Promise<NotificationExactUnlinkResult>;
|
|
832
|
+
};
|
|
833
|
+
|
|
834
|
+
async function readTransitionMarker(
|
|
835
|
+
fs: TransitionMarkerFs,
|
|
836
|
+
path: string,
|
|
837
|
+
): Promise<TransitionMarkerSnapshot | undefined> {
|
|
838
|
+
if (fs.readEndpointFile) {
|
|
839
|
+
const exact = await fs.readEndpointFile(path).catch(() => undefined);
|
|
840
|
+
if (!exact) return undefined;
|
|
841
|
+
return { raw: exact.bytes.toString("utf8"), identity: exact.identity };
|
|
842
|
+
}
|
|
843
|
+
const raw = await fs.readFile(path, "utf8").catch(() => undefined);
|
|
844
|
+
if (raw === undefined) return undefined;
|
|
845
|
+
return { raw };
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
function transitionMarkerMatchesLock(snapshot: TransitionMarkerSnapshot, lock: DaemonTransitionLock): boolean {
|
|
849
|
+
try {
|
|
850
|
+
const current = JSON.parse(snapshot.raw);
|
|
851
|
+
return (
|
|
852
|
+
isDaemonTransitionLock(current) &&
|
|
853
|
+
current.pid === lock.pid &&
|
|
854
|
+
current.incarnation === lock.incarnation &&
|
|
855
|
+
current.token === lock.token
|
|
856
|
+
);
|
|
857
|
+
} catch {
|
|
858
|
+
return false;
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/** True only while the exact transition acquisition still occupies the marker path. */
|
|
863
|
+
export async function daemonTransitionLockIsHeld(input: {
|
|
864
|
+
fs: Pick<TransitionMarkerFs, "readFile" | "readEndpointFile">;
|
|
865
|
+
path: string;
|
|
866
|
+
lock: DaemonTransitionLock;
|
|
867
|
+
}): Promise<boolean> {
|
|
868
|
+
const snapshot = await readTransitionMarker(input.fs, input.path);
|
|
869
|
+
return Boolean(snapshot && transitionMarkerMatchesLock(snapshot, input.lock));
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
/** Atomically detaches only the captured transition marker, never a pathname successor. */
|
|
873
|
+
async function detachTransitionMarker(
|
|
874
|
+
fs: TransitionMarkerFs,
|
|
875
|
+
path: string,
|
|
876
|
+
snapshot: TransitionMarkerSnapshot,
|
|
877
|
+
): Promise<boolean> {
|
|
878
|
+
if (!snapshot.identity || !fs.exactUnlink) return false;
|
|
879
|
+
try {
|
|
880
|
+
return (await fs.exactUnlink(path, snapshot.identity)).ok;
|
|
881
|
+
} catch {
|
|
882
|
+
return false;
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
/** Removes only the caller's exact marker through the identity-bound detach primitive. */
|
|
887
|
+
export async function releaseDaemonTransitionLock(input: {
|
|
888
|
+
fs: TransitionMarkerFs;
|
|
889
|
+
path: string;
|
|
890
|
+
lock: DaemonTransitionLock;
|
|
891
|
+
}): Promise<boolean> {
|
|
892
|
+
const snapshot = await readTransitionMarker(input.fs, input.path);
|
|
893
|
+
if (!snapshot || !transitionMarkerMatchesLock(snapshot, input.lock)) return false;
|
|
894
|
+
return await detachTransitionMarker(input.fs, input.path, snapshot);
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
/**
|
|
898
|
+
* Acquire the daemon lifecycle transition lock using durable owner metadata.
|
|
899
|
+
* The full marker is published in the single O_EXCL write which reserves it;
|
|
900
|
+
* canonical markers are detached only through their captured filesystem identity.
|
|
901
|
+
*
|
|
902
|
+
* Malformed and empty markers deliberately remain blocked regardless of age. They
|
|
903
|
+
* have no owner provenance, so reclaiming them could detach a generation-6 empty
|
|
904
|
+
* reservation while its paused legacy writer can still resume. Operators must
|
|
905
|
+
* manually clean up such legacy debris after confirming no legacy process remains.
|
|
906
|
+
*/
|
|
907
|
+
export async function acquireDaemonTransitionLock(input: {
|
|
908
|
+
fs: TransitionMarkerFs;
|
|
909
|
+
path: string;
|
|
910
|
+
pid: number;
|
|
911
|
+
pidAlive: (pid: number) => boolean;
|
|
912
|
+
pidIncarnation: (pid: number) => string | undefined;
|
|
913
|
+
now?: () => number;
|
|
914
|
+
sleep?: (ms: number) => Promise<void>;
|
|
915
|
+
retries?: number;
|
|
916
|
+
retryDelayMs?: number;
|
|
917
|
+
randomToken?: () => string;
|
|
918
|
+
}): Promise<DaemonTransitionLock | undefined> {
|
|
919
|
+
const sleep = input.sleep ?? (async (ms: number) => await Bun.sleep(ms));
|
|
920
|
+
const now = input.now ?? Date.now;
|
|
921
|
+
const retries = Math.max(input.retries ?? 5, 0);
|
|
922
|
+
const retryDelayMs = Math.max(input.retryDelayMs ?? 20, 0);
|
|
923
|
+
const incarnation = input.pidIncarnation(input.pid);
|
|
924
|
+
if (!isProcessIncarnation(incarnation)) return undefined;
|
|
925
|
+
const token = input.randomToken?.() ?? crypto.randomUUID();
|
|
926
|
+
if (!token || !input.fs.writeFile || !input.fs.readEndpointFile || !input.fs.exactUnlink) return undefined;
|
|
927
|
+
const owner: DaemonTransitionLock = { pid: input.pid, incarnation, createdAt: now(), token };
|
|
928
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
929
|
+
try {
|
|
930
|
+
await input.fs.writeFile(input.path, `${JSON.stringify(owner)}\n`, { mode: 0o600, flag: "wx" });
|
|
931
|
+
return owner;
|
|
932
|
+
} catch (error) {
|
|
933
|
+
if (["EEXIST", "ENOENT"].includes((error as NodeJS.ErrnoException).code ?? "")) {
|
|
934
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
935
|
+
} else throw error;
|
|
936
|
+
}
|
|
937
|
+
const snapshot = await readTransitionMarker(input.fs, input.path);
|
|
938
|
+
let current: unknown;
|
|
939
|
+
try {
|
|
940
|
+
current = snapshot === undefined ? undefined : JSON.parse(snapshot.raw);
|
|
941
|
+
} catch {
|
|
942
|
+
current = undefined;
|
|
943
|
+
}
|
|
944
|
+
// Only canonical owner records prove which process may be reclaimed. An
|
|
945
|
+
// unreadable or empty legacy reservation is intentionally not age-reclaimed:
|
|
946
|
+
// it may belong to a paused generation-6 two-step publisher.
|
|
947
|
+
const ownerAlive = isDaemonTransitionLock(current) && input.pidAlive(current.pid);
|
|
948
|
+
const ownerIncarnation = isDaemonTransitionLock(current) ? input.pidIncarnation(current.pid) : undefined;
|
|
949
|
+
const reclaimable =
|
|
950
|
+
isDaemonTransitionLock(current) &&
|
|
951
|
+
(!ownerAlive ||
|
|
952
|
+
(isProcessIncarnation(current.incarnation) &&
|
|
953
|
+
isProcessIncarnation(ownerIncarnation) &&
|
|
954
|
+
ownerIncarnation !== current.incarnation));
|
|
955
|
+
if (snapshot && reclaimable) await detachTransitionMarker(input.fs, input.path, snapshot);
|
|
956
|
+
if (attempt < retries) await sleep(retryDelayMs);
|
|
957
|
+
}
|
|
958
|
+
return undefined;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
function defaultTransitionPidAlive(pid: number): boolean {
|
|
962
|
+
try {
|
|
963
|
+
process.kill(pid, 0);
|
|
964
|
+
return true;
|
|
965
|
+
} catch (err) {
|
|
966
|
+
return (err as NodeJS.ErrnoException).code === "EPERM";
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
function defaultTransitionPidIncarnation(pid: number): string | undefined {
|
|
971
|
+
return processIncarnation(pid);
|
|
972
|
+
}
|
|
973
|
+
|
|
783
974
|
/**
|
|
784
975
|
* Owner-bound removal of a dead daemon's lock. Closes the classic
|
|
785
976
|
* check-then-unlink TOCTOU: a naive `unlink(lock)` after observing a dead owner
|
|
@@ -795,13 +986,21 @@ async function removeDeadOwnerLock(
|
|
|
795
986
|
pidAlive: (pid: number) => boolean,
|
|
796
987
|
expected: NormalizedDaemonState,
|
|
797
988
|
): Promise<"cleared" | "contended" | "superseded" | "now-alive" | "unlink-failed"> {
|
|
798
|
-
|
|
989
|
+
const transition = await acquireDaemonTransitionLock({
|
|
990
|
+
fs,
|
|
991
|
+
path: paths.steal,
|
|
992
|
+
pid: process.pid,
|
|
993
|
+
pidAlive: defaultTransitionPidAlive,
|
|
994
|
+
pidIncarnation: defaultTransitionPidIncarnation,
|
|
995
|
+
});
|
|
996
|
+
if (!transition) return "contended";
|
|
799
997
|
try {
|
|
800
998
|
const current = await readDaemonStateFile(fs, paths.state);
|
|
801
999
|
if (!current || current.ownerId !== expected.ownerId || current.pid !== expected.pid) {
|
|
802
1000
|
return "superseded";
|
|
803
1001
|
}
|
|
804
1002
|
if (pidAlive(current.pid)) return "now-alive";
|
|
1003
|
+
if (!(await daemonTransitionLockIsHeld({ fs, path: paths.steal, lock: transition }))) return "contended";
|
|
805
1004
|
try {
|
|
806
1005
|
await fs.unlink(paths.lock);
|
|
807
1006
|
return "cleared";
|
|
@@ -809,7 +1008,7 @@ async function removeDeadOwnerLock(
|
|
|
809
1008
|
return "unlink-failed";
|
|
810
1009
|
}
|
|
811
1010
|
} finally {
|
|
812
|
-
await fs
|
|
1011
|
+
await releaseDaemonTransitionLock({ fs, path: paths.steal, lock: transition });
|
|
813
1012
|
}
|
|
814
1013
|
}
|
|
815
1014
|
|
|
@@ -833,6 +1032,10 @@ export async function recoverNotifications(opts: RecoveryOptions): Promise<Notif
|
|
|
833
1032
|
const files = await listEndpointFiles(fs, dir);
|
|
834
1033
|
const removed: RecoveredEndpoint[] = [];
|
|
835
1034
|
const detached: string[] = [];
|
|
1035
|
+
const retainedSuccessors: string[] = [];
|
|
1036
|
+
const retainedPlaceholders: string[] = [];
|
|
1037
|
+
const retainedUnknown: string[] = [];
|
|
1038
|
+
let recoveryFailures = 0;
|
|
836
1039
|
let kept = 0;
|
|
837
1040
|
let unreadable = 0;
|
|
838
1041
|
for (const name of files) {
|
|
@@ -854,8 +1057,18 @@ export async function recoverNotifications(opts: RecoveryOptions): Promise<Notif
|
|
|
854
1057
|
try {
|
|
855
1058
|
const result = await fs.exactUnlink(file, record.identity);
|
|
856
1059
|
if (!result.ok) {
|
|
1060
|
+
recoveryFailures += 1;
|
|
857
1061
|
if (result.detachedPath) detached.push(result.detachedPath);
|
|
858
|
-
|
|
1062
|
+
if (result.retainedSuccessorPath) retainedSuccessors.push(result.retainedSuccessorPath);
|
|
1063
|
+
if (result.retainedPlaceholderPath) retainedPlaceholders.push(result.retainedPlaceholderPath);
|
|
1064
|
+
if (result.retainedUnknownPath) retainedUnknown.push(result.retainedUnknownPath);
|
|
1065
|
+
if (
|
|
1066
|
+
!result.detachedPath &&
|
|
1067
|
+
!result.retainedSuccessorPath &&
|
|
1068
|
+
!result.retainedPlaceholderPath &&
|
|
1069
|
+
!result.retainedUnknownPath
|
|
1070
|
+
)
|
|
1071
|
+
kept += 1;
|
|
859
1072
|
continue;
|
|
860
1073
|
}
|
|
861
1074
|
removed.push({
|
|
@@ -928,11 +1141,14 @@ export async function recoverNotifications(opts: RecoveryOptions): Promise<Notif
|
|
|
928
1141
|
}
|
|
929
1142
|
|
|
930
1143
|
return {
|
|
931
|
-
endpointsScanned: removed.length + kept + unreadable +
|
|
1144
|
+
endpointsScanned: removed.length + kept + unreadable + recoveryFailures,
|
|
932
1145
|
endpointsRemoved: removed,
|
|
933
1146
|
endpointsKept: kept,
|
|
934
1147
|
endpointsUnreadable: unreadable,
|
|
935
1148
|
endpointsDetached: detached,
|
|
1149
|
+
endpointsRetainedSuccessors: retainedSuccessors,
|
|
1150
|
+
endpointsRetainedPlaceholders: retainedPlaceholders,
|
|
1151
|
+
endpointsRetainedUnknown: retainedUnknown,
|
|
936
1152
|
daemon,
|
|
937
1153
|
};
|
|
938
1154
|
}
|
|
@@ -941,12 +1157,18 @@ export async function recoverNotifications(opts: RecoveryOptions): Promise<Notif
|
|
|
941
1157
|
export function formatNotificationRecoveryReport(report: NotificationRecoveryReport): string {
|
|
942
1158
|
const lines = ["Notification recovery"];
|
|
943
1159
|
lines.push(
|
|
944
|
-
` endpoints: scanned ${report.endpointsScanned}, removed ${report.endpointsRemoved.length}, kept ${report.endpointsKept}, unreadable ${report.endpointsUnreadable}, detached ${report.endpointsDetached?.length ?? 0}`,
|
|
1160
|
+
` endpoints: scanned ${report.endpointsScanned}, removed ${report.endpointsRemoved.length}, kept ${report.endpointsKept}, unreadable ${report.endpointsUnreadable}, detached ${report.endpointsDetached?.length ?? 0}, retained successors ${report.endpointsRetainedSuccessors?.length ?? 0}, retained placeholders ${report.endpointsRetainedPlaceholders?.length ?? 0}, retained unknown ${report.endpointsRetainedUnknown?.length ?? 0}`,
|
|
945
1161
|
);
|
|
946
1162
|
for (const ep of report.endpointsRemoved) {
|
|
947
1163
|
lines.push(` - removed ${ep.sessionId} (pid ${ep.pid ?? "?"}, ${ep.reason})`);
|
|
948
1164
|
}
|
|
949
1165
|
for (const detached of report.endpointsDetached ?? []) lines.push(` - retained detached endpoint ${detached}`);
|
|
1166
|
+
for (const successor of report.endpointsRetainedSuccessors ?? [])
|
|
1167
|
+
lines.push(` - retained successor endpoint ${successor}`);
|
|
1168
|
+
for (const placeholder of report.endpointsRetainedPlaceholders ?? [])
|
|
1169
|
+
lines.push(` - retained exchange placeholder cleanup path ${placeholder}`);
|
|
1170
|
+
for (const unknown of report.endpointsRetainedUnknown ?? [])
|
|
1171
|
+
lines.push(` - retained unverified cleanup path ${unknown}`);
|
|
950
1172
|
lines.push(` daemon: ${report.daemon.action} — ${report.daemon.detail}`);
|
|
951
1173
|
return lines.join("\n");
|
|
952
1174
|
}
|
|
@@ -154,10 +154,12 @@ async function resolveRecentScopes(
|
|
|
154
154
|
agentDir,
|
|
155
155
|
sessionsRoot,
|
|
156
156
|
});
|
|
157
|
-
if (
|
|
158
|
-
resolved.
|
|
159
|
-
|
|
160
|
-
|
|
157
|
+
if (resolved.kind !== "resolved") {
|
|
158
|
+
if (resolved.code !== "cwd_missing" && resolved.code !== "cwd_not_directory")
|
|
159
|
+
warnings.push("Ignored invalid managed session scope binding.");
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (expectedDirectory !== undefined && resolved.scope.directoryPath !== expectedDirectory) {
|
|
161
163
|
warnings.push("Ignored invalid managed session scope binding.");
|
|
162
164
|
return;
|
|
163
165
|
}
|
|
@@ -232,7 +234,14 @@ export async function listRecentSessions(deps: RecentActivityDeps): Promise<List
|
|
|
232
234
|
return { kind: "error", code: "managed_scan_failed", message: listed.message };
|
|
233
235
|
}
|
|
234
236
|
candidates.push(...listed.owned);
|
|
235
|
-
warnings.push(
|
|
237
|
+
warnings.push(
|
|
238
|
+
...listed.invalid
|
|
239
|
+
.filter(
|
|
240
|
+
invalid =>
|
|
241
|
+
!deps.allWorkspaces || (invalid.code !== "cwd_not_found" && invalid.code !== "cwd_not_directory"),
|
|
242
|
+
)
|
|
243
|
+
.map(invalid => `Ignored invalid managed session candidate: ${invalid.code}`),
|
|
244
|
+
);
|
|
236
245
|
}
|
|
237
246
|
|
|
238
247
|
const entries: RecentSessionEntry[] = [];
|