@gajae-code/coding-agent 0.12.7 → 0.12.8
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 +19 -0
- package/dist/types/commands/notify.d.ts +4 -0
- package/dist/types/config/file-lock.d.ts +3 -0
- package/dist/types/config/model-discovery-manager.d.ts +6 -1
- package/dist/types/config/model-registry.d.ts +2 -0
- package/dist/types/config/resolve-config-value.d.ts +2 -1
- package/dist/types/config/settings-schema.d.ts +17 -1
- package/dist/types/main.d.ts +1 -0
- package/dist/types/modes/components/hook-selector.d.ts +20 -0
- package/dist/types/modes/components/notifications-settings-editor.d.ts +53 -4
- package/dist/types/modes/controllers/extension-ui-controller.d.ts +1 -1
- package/dist/types/modes/controllers/selector-controller.d.ts +4 -2
- package/dist/types/sdk/broker/broker.d.ts +5 -1
- package/dist/types/sdk/broker/lifecycle-ledger.d.ts +1 -0
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +11 -1
- package/dist/types/sdk/bus/config.d.ts +75 -113
- package/dist/types/sdk/bus/discord-live-provider.d.ts +8 -2
- package/dist/types/sdk/bus/discord-provider.d.ts +19 -0
- package/dist/types/sdk/bus/index.d.ts +1 -1
- package/dist/types/sdk/bus/notification-orchestration.d.ts +127 -18
- package/dist/types/sdk/bus/notification-service.d.ts +31 -4
- package/dist/types/sdk/bus/session-control.d.ts +8 -3
- package/dist/types/sdk/bus/slack-live-provider.d.ts +9 -2
- package/dist/types/sdk/bus/slack-provider.d.ts +22 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +8 -1
- package/dist/types/sdk/client/discovery.d.ts +2 -1
- package/dist/types/sdk/host/query/handlers.d.ts +2 -0
- package/dist/types/sdk/index.d.ts +1 -0
- package/dist/types/sdk/providers.d.ts +22 -0
- package/dist/types/sdk/session-directory.d.ts +1 -1
- package/dist/types/session/internal/managed-session-scope.d.ts +17 -1
- package/dist/types/session/internal/managed-session-storage.d.ts +11 -1
- package/dist/types/session/internal/native-publish-outcome.d.ts +1 -0
- package/dist/types/session/internal/session-open-errors.d.ts +5 -0
- package/dist/types/session/session-manager.d.ts +3 -1
- package/dist/types/session/session-storage.d.ts +2 -0
- package/package.json +10 -8
- package/scripts/build-sdk-package-smoke.ts +51 -0
- package/scripts/capture-notifications-settings-showcase.ts +1 -1
- package/src/cli/notify-cli.ts +204 -94
- package/src/commands/notify.ts +19 -8
- package/src/config/file-lock.ts +6 -0
- package/src/config/model-discovery-manager.ts +50 -8
- package/src/config/model-registry.ts +862 -75
- package/src/config/resolve-config-value.ts +10 -8
- package/src/config/settings-schema.ts +25 -15
- package/src/config/settings.ts +15 -15
- package/src/gjc-runtime/session-state-sidecar.ts +12 -2
- package/src/internal-urls/docs-index.generated.ts +6 -6
- package/src/main.ts +43 -7
- package/src/modes/DESIGN.md +11 -11
- package/src/modes/components/hook-selector.ts +155 -44
- package/src/modes/components/model-selector.ts +242 -49
- package/src/modes/components/notifications-settings-editor.ts +631 -85
- package/src/modes/controllers/extension-ui-controller.ts +114 -15
- package/src/modes/controllers/selector-controller.ts +464 -50
- package/src/sdk/broker/broker.ts +54 -10
- package/src/sdk/broker/lifecycle-ledger.ts +34 -1
- package/src/sdk/broker/lifecycle.ts +47 -50
- package/src/sdk/bus/chat-daemon-cli.ts +12 -5
- package/src/sdk/bus/chat-daemon-control.ts +35 -6
- package/src/sdk/bus/chat-daemon-runtime.ts +16 -2
- package/src/sdk/bus/config.ts +534 -286
- package/src/sdk/bus/discord-live-provider.ts +57 -2
- package/src/sdk/bus/discord-provider.ts +21 -0
- package/src/sdk/bus/index.ts +97 -35
- package/src/sdk/bus/notification-orchestration.ts +371 -68
- package/src/sdk/bus/notification-service.ts +431 -130
- package/src/sdk/bus/session-control.ts +119 -44
- package/src/sdk/bus/slack-live-provider.ts +78 -2
- package/src/sdk/bus/slack-provider.ts +24 -0
- package/src/sdk/bus/telegram-daemon-cli.ts +9 -2
- package/src/sdk/bus/telegram-daemon-contract.ts +8 -1
- package/src/sdk/bus/telegram-daemon-control.ts +3 -3
- package/src/sdk/bus/telegram-daemon.ts +23 -15
- package/src/sdk/client/discovery.ts +10 -4
- package/src/sdk/host/query/handlers.ts +20 -1
- package/src/sdk/index.ts +1 -0
- package/src/sdk/protocol/operation-inventory.generated.json +18 -0
- package/src/sdk/protocol/operation-registry.ts +5 -2
- package/src/sdk/providers.ts +51 -0
- package/src/sdk/session-directory.ts +1 -0
- package/src/sdk/session.ts +10 -7
- package/src/session/agent-session.ts +44 -24
- package/src/session/internal/managed-session-scope.ts +981 -450
- package/src/session/internal/managed-session-storage.ts +382 -45
- package/src/session/internal/native-publish-outcome.ts +3 -1
- package/src/session/internal/session-open-errors.ts +11 -0
- package/src/session/session-manager.ts +27 -3
- package/src/session/session-storage.ts +72 -5
- package/src/slash-commands/builtin-registry.ts +82 -4
- package/src/task/index.ts +9 -3
- package/src/tools/ask.ts +1 -1
- package/src/tools/bash.ts +1 -1
- package/src/tools/telegram-send.ts +4 -2
package/src/sdk/broker/broker.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
import {
|
|
13
13
|
BROKER_HEARTBEAT_TTL_MS,
|
|
14
14
|
type BrokerDiscovery,
|
|
15
|
+
type BrokerPublicationObservation,
|
|
15
16
|
brokerDiscoveryPath,
|
|
16
17
|
brokerProcessIncarnation,
|
|
17
18
|
heartbeatBrokerDiscoveryRetained,
|
|
@@ -396,6 +397,14 @@ type BrokerLockSnapshot = {
|
|
|
396
397
|
|
|
397
398
|
const BROKER_PUBLICATION_CADENCE_MS = 5_000;
|
|
398
399
|
const BROKER_PUBLICATION_GRACE_MS = 15_000;
|
|
400
|
+
// A broker that cannot observe its own publication is not provably the root, but
|
|
401
|
+
// ambiguity is also not proof of replacement, so it must not be treated as a
|
|
402
|
+
// `lost-root` immediately. It must still be bounded: an indefinitely ambiguous
|
|
403
|
+
// broker stops heartbeating, so peers discover it as stale and spawn replacements
|
|
404
|
+
// while it keeps its port and memory forever. Ambiguity therefore accrues against
|
|
405
|
+
// its own deadline, generous enough to absorb transient filesystem faults and far
|
|
406
|
+
// longer than the loss grace.
|
|
407
|
+
const BROKER_AMBIGUITY_GRACE_MS = 120_000;
|
|
399
408
|
const BROKER_SETTLEMENT_MS = 2_000;
|
|
400
409
|
type BrokerPublicationState =
|
|
401
410
|
| "healthy-owned"
|
|
@@ -406,6 +415,8 @@ type BrokerPublicationState =
|
|
|
406
415
|
type BrokerStopMode = "owned-root" | "lost-root";
|
|
407
416
|
|
|
408
417
|
const terminalPersistenceHooksForTest = new WeakMap<Broker, () => void>();
|
|
418
|
+
const ambiguityGraceOverridesForTest = new WeakMap<Broker, number>();
|
|
419
|
+
const publicationObservationOverridesForTest = new WeakMap<Broker, BrokerPublicationObservation>();
|
|
409
420
|
|
|
410
421
|
export class Broker {
|
|
411
422
|
readonly settings: ResolvedBrokerSettings;
|
|
@@ -419,6 +430,7 @@ export class Broker {
|
|
|
419
430
|
#publication: RetainedBrokerDiscovery | null = null;
|
|
420
431
|
#publicationState: BrokerPublicationState = "healthy-owned";
|
|
421
432
|
#lossAt: bigint | null = null;
|
|
433
|
+
#ambiguousAt: bigint | null = null;
|
|
422
434
|
#stopping = false;
|
|
423
435
|
#transport: BrokerTransport | null = null;
|
|
424
436
|
#heartbeatTimer: NodeJS.Timeout | null = null;
|
|
@@ -565,6 +577,7 @@ export class Broker {
|
|
|
565
577
|
this.#stopping = false;
|
|
566
578
|
this.#publicationState = "healthy-owned";
|
|
567
579
|
this.#lossAt = null;
|
|
580
|
+
this.#ambiguousAt = null;
|
|
568
581
|
await Promise.all([this.ledger.assertSupportedStateVersions(), readBrokerDiscovery(this.settings.agentDir)]);
|
|
569
582
|
await fs.mkdir(path.dirname(this.#lock), { recursive: true, mode: 0o700 });
|
|
570
583
|
for (;;) {
|
|
@@ -648,16 +661,35 @@ export class Broker {
|
|
|
648
661
|
#fence(kind: "suspect-unpublished" | "observation-ambiguous" | "heartbeat-ambiguous"): void {
|
|
649
662
|
if (this.#publicationState === "stopping") return;
|
|
650
663
|
this.#publicationState = kind;
|
|
651
|
-
if (kind === "suspect-unpublished")
|
|
652
|
-
|
|
664
|
+
if (kind === "suspect-unpublished") {
|
|
665
|
+
this.#lossAt ??= process.hrtime.bigint();
|
|
666
|
+
this.#ambiguousAt = null;
|
|
667
|
+
} else {
|
|
668
|
+
this.#lossAt = null;
|
|
669
|
+
this.#ambiguousAt ??= process.hrtime.bigint();
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
/**
|
|
673
|
+
* Whether this broker has been unable to confirm it is the published root for
|
|
674
|
+
* longer than the deadline for its current fence. Replacement is proven quickly
|
|
675
|
+
* and ambiguity slowly, but neither may persist indefinitely: a permanently
|
|
676
|
+
* fenced broker never heartbeats, so it is unreachable through discovery while
|
|
677
|
+
* still holding its port and memory.
|
|
678
|
+
*/
|
|
679
|
+
#fencedBeyondDeadline(): boolean {
|
|
680
|
+
const now = process.hrtime.bigint();
|
|
681
|
+
if (this.#lossAt !== null && now - this.#lossAt >= BigInt(BROKER_PUBLICATION_GRACE_MS) * 1_000_000n) return true;
|
|
682
|
+
const ambiguityGraceMs = ambiguityGraceOverridesForTest.get(this) ?? BROKER_AMBIGUITY_GRACE_MS;
|
|
683
|
+
return this.#ambiguousAt !== null && now - this.#ambiguousAt >= BigInt(ambiguityGraceMs) * 1_000_000n;
|
|
653
684
|
}
|
|
654
685
|
async #watchPublication(writeHeartbeat = true): Promise<void> {
|
|
655
686
|
if (!this.#publication || this.#publicationState === "stopping") return;
|
|
656
687
|
let observation: ReturnType<RetainedBrokerDiscovery["observe"]>;
|
|
657
688
|
try {
|
|
658
|
-
observation = this.#publication.observe();
|
|
689
|
+
observation = publicationObservationOverridesForTest.get(this) ?? this.#publication.observe();
|
|
659
690
|
} catch {
|
|
660
691
|
this.#fence("observation-ambiguous");
|
|
692
|
+
if (this.#fencedBeyondDeadline()) void this.#complete("lost-root");
|
|
661
693
|
return;
|
|
662
694
|
}
|
|
663
695
|
if (observation === "owned") {
|
|
@@ -667,15 +699,12 @@ export class Broker {
|
|
|
667
699
|
}
|
|
668
700
|
this.#publicationState = "healthy-owned";
|
|
669
701
|
this.#lossAt = null;
|
|
702
|
+
this.#ambiguousAt = null;
|
|
670
703
|
if (writeHeartbeat) await this.#writeHeartbeat();
|
|
671
704
|
return;
|
|
672
705
|
}
|
|
673
706
|
this.#fence(observation === "ambiguous" ? "observation-ambiguous" : "suspect-unpublished");
|
|
674
|
-
if (
|
|
675
|
-
this.#lossAt !== null &&
|
|
676
|
-
process.hrtime.bigint() - this.#lossAt >= BigInt(BROKER_PUBLICATION_GRACE_MS) * 1_000_000n
|
|
677
|
-
)
|
|
678
|
-
void this.#complete("lost-root");
|
|
707
|
+
if (this.#fencedBeyondDeadline()) void this.#complete("lost-root");
|
|
679
708
|
}
|
|
680
709
|
async #writeHeartbeat(): Promise<void> {
|
|
681
710
|
if (!this.discovery || !this.#publication || this.#publicationState === "stopping") return;
|
|
@@ -691,6 +720,7 @@ export class Broker {
|
|
|
691
720
|
}
|
|
692
721
|
this.#publicationState = "healthy-owned";
|
|
693
722
|
this.#lossAt = null;
|
|
723
|
+
this.#ambiguousAt = null;
|
|
694
724
|
this.discovery = { ...this.discovery, heartbeatAt };
|
|
695
725
|
}
|
|
696
726
|
async heartbeat(): Promise<void> {
|
|
@@ -938,9 +968,8 @@ export class Broker {
|
|
|
938
968
|
});
|
|
939
969
|
if (isCleanupPending(response)) return response;
|
|
940
970
|
const persisted = await this.ledger.readTerminal(identity, requestHash);
|
|
941
|
-
const expectedResponseDigest = createHash("sha256").update(canonicalJson(response)).digest("hex");
|
|
942
971
|
const persistenceVerified =
|
|
943
|
-
persisted
|
|
972
|
+
persisted !== undefined &&
|
|
944
973
|
canonicalJson(persisted.response) === canonicalJson(response) &&
|
|
945
974
|
canonicalJson(persisted.durableEffects) === canonicalJson(outcome.durableEffects) &&
|
|
946
975
|
canonicalJson(persisted.startupFailure) === canonicalJson(outcome.startupFailure);
|
|
@@ -972,3 +1001,18 @@ export function setTerminalPersistenceHookForTest(broker: Broker, hook: (() => v
|
|
|
972
1001
|
if (hook) terminalPersistenceHooksForTest.set(broker, hook);
|
|
973
1002
|
else terminalPersistenceHooksForTest.delete(broker);
|
|
974
1003
|
}
|
|
1004
|
+
|
|
1005
|
+
/** Test-only hook for shortening the bounded ambiguity deadline. */
|
|
1006
|
+
export function setAmbiguityGraceForTest(broker: Broker, graceMs: number | undefined): void {
|
|
1007
|
+
if (graceMs === undefined) ambiguityGraceOverridesForTest.delete(broker);
|
|
1008
|
+
else ambiguityGraceOverridesForTest.set(broker, graceMs);
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
/** Test-only hook for forcing the observation the publication watchdog sees. */
|
|
1012
|
+
export function setPublicationObservationForTest(
|
|
1013
|
+
broker: Broker,
|
|
1014
|
+
observation: BrokerPublicationObservation | undefined,
|
|
1015
|
+
): void {
|
|
1016
|
+
if (observation === undefined) publicationObservationOverridesForTest.delete(broker);
|
|
1017
|
+
else publicationObservationOverridesForTest.set(broker, observation);
|
|
1018
|
+
}
|
|
@@ -666,7 +666,13 @@ export class LifecycleLedger {
|
|
|
666
666
|
return this.#mutate(async () => {
|
|
667
667
|
const previous = this.#byIdentity.get(identity);
|
|
668
668
|
if (!previous) throw new Error("Unknown lifecycle identity");
|
|
669
|
-
const next = {
|
|
669
|
+
const next = {
|
|
670
|
+
...previous,
|
|
671
|
+
...fields,
|
|
672
|
+
state,
|
|
673
|
+
ts: Date.now(),
|
|
674
|
+
...(fields.response !== undefined ? { response: fields.response } : {}),
|
|
675
|
+
};
|
|
670
676
|
if (this.#isCleanupPending(next)) {
|
|
671
677
|
next.unresolvedCleanupResponse = undefined;
|
|
672
678
|
next.unresolvedCleanupResponseDigest = undefined;
|
|
@@ -796,4 +802,31 @@ export class LifecycleLedger {
|
|
|
796
802
|
get(identity: string): LifecycleLedgerEntry | undefined {
|
|
797
803
|
return this.#byIdentity.get(identity);
|
|
798
804
|
}
|
|
805
|
+
|
|
806
|
+
findPendingCleanupByTarget(
|
|
807
|
+
sessionId: string,
|
|
808
|
+
cwd: string,
|
|
809
|
+
transcriptPath: string,
|
|
810
|
+
): LifecycleLedgerEntry | undefined {
|
|
811
|
+
const expectedCwd = path.resolve(cwd);
|
|
812
|
+
const expectedTranscript = path.resolve(transcriptPath);
|
|
813
|
+
let latest: LifecycleLedgerEntry | undefined;
|
|
814
|
+
for (const entry of this.#byIdentity.values()) {
|
|
815
|
+
if (entry.state !== "effect_started" || !this.#isCleanupPending(entry)) continue;
|
|
816
|
+
const response = entry.response as {
|
|
817
|
+
error?: { cleanup?: { sessionId?: unknown; cwd?: unknown; transcriptPath?: unknown } };
|
|
818
|
+
};
|
|
819
|
+
const cleanup = response.error?.cleanup;
|
|
820
|
+
if (
|
|
821
|
+
cleanup?.sessionId !== sessionId ||
|
|
822
|
+
typeof cleanup.cwd !== "string" ||
|
|
823
|
+
typeof cleanup.transcriptPath !== "string" ||
|
|
824
|
+
path.resolve(cleanup.cwd) !== expectedCwd ||
|
|
825
|
+
path.resolve(cleanup.transcriptPath) !== expectedTranscript
|
|
826
|
+
)
|
|
827
|
+
continue;
|
|
828
|
+
if (!latest || entry.ts > latest.ts) latest = entry;
|
|
829
|
+
}
|
|
830
|
+
return latest;
|
|
831
|
+
}
|
|
799
832
|
}
|
|
@@ -865,7 +865,7 @@ export async function writeSessionLifecycleReady(root: string, id: string, effec
|
|
|
865
865
|
const incarnation = processIncarnation(process.pid);
|
|
866
866
|
if (!incarnation) throw new Error("Lifecycle child has no readable OS incarnation.");
|
|
867
867
|
await fs.mkdir(path.join(root, "sdk"), { recursive: true, mode: 0o700 });
|
|
868
|
-
await fs.writeFile(lifecycleReadyPath(root, id),
|
|
868
|
+
await fs.writeFile(lifecycleReadyPath(root, id), canonicalJson({ pid: process.pid, effectMarker, incarnation }), {
|
|
869
869
|
mode: 0o600,
|
|
870
870
|
});
|
|
871
871
|
}
|
|
@@ -1365,15 +1365,6 @@ function lifecycleMetadataReplayFiles(cleanup: CleanupEvidence): LifecycleCleanu
|
|
|
1365
1365
|
return cleanup.lifecycleFiles?.length ? cleanup.lifecycleFiles : undefined;
|
|
1366
1366
|
}
|
|
1367
1367
|
|
|
1368
|
-
function absentLifecyclePath(file: string): boolean {
|
|
1369
|
-
try {
|
|
1370
|
-
fsSync.lstatSync(file);
|
|
1371
|
-
return false;
|
|
1372
|
-
} catch (error) {
|
|
1373
|
-
return (error as NodeJS.ErrnoException).code === "ENOENT";
|
|
1374
|
-
}
|
|
1375
|
-
}
|
|
1376
|
-
|
|
1377
1368
|
function isLifecycleCleanupResponse(value: LifecycleFileCapture | BrokerResponse | undefined): value is BrokerResponse {
|
|
1378
1369
|
return typeof value === "object" && value !== null && "ok" in value;
|
|
1379
1370
|
}
|
|
@@ -1423,6 +1414,19 @@ function validateLifecycleMetadataReplay(cleanup: CleanupEvidence): BrokerRespon
|
|
|
1423
1414
|
try {
|
|
1424
1415
|
current = captureLifecycleFile(candidate, true, true);
|
|
1425
1416
|
} catch {
|
|
1417
|
+
if (
|
|
1418
|
+
file.completed &&
|
|
1419
|
+
[file.detachedPath, file.plannedPath].some(
|
|
1420
|
+
bound => bound && path.resolve(candidate) === path.resolve(bound),
|
|
1421
|
+
)
|
|
1422
|
+
) {
|
|
1423
|
+
try {
|
|
1424
|
+
const stat = fsSync.lstatSync(candidate);
|
|
1425
|
+
if (stat.isFile() && !stat.isSymbolicLink() && stat.nlink === 1 && stat.size === 0) continue;
|
|
1426
|
+
} catch (error) {
|
|
1427
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1426
1430
|
return fail("terminal_uncertain", "Lifecycle metadata candidate could not be safely inspected.");
|
|
1427
1431
|
}
|
|
1428
1432
|
if (!current) continue;
|
|
@@ -1457,7 +1461,7 @@ function validateLifecycleMetadataReplay(cleanup: CleanupEvidence): BrokerRespon
|
|
|
1457
1461
|
if (!markerEntry)
|
|
1458
1462
|
return fail("terminal_uncertain", "Lifecycle readiness metadata lacks canonical marker authority.");
|
|
1459
1463
|
if (!marker) {
|
|
1460
|
-
if (createHash("sha256").update(
|
|
1464
|
+
if (createHash("sha256").update(ready.bytes).digest("hex") !== markerEntry.identity.sha256)
|
|
1461
1465
|
return fail(
|
|
1462
1466
|
"terminal_uncertain",
|
|
1463
1467
|
"Lifecycle readiness metadata is not bound to the completed marker authority.",
|
|
@@ -1473,30 +1477,6 @@ function validateLifecycleMetadataReplay(cleanup: CleanupEvidence): BrokerRespon
|
|
|
1473
1477
|
return undefined;
|
|
1474
1478
|
}
|
|
1475
1479
|
|
|
1476
|
-
function lifecycleMetadataReplayAbsent(cleanup: CleanupEvidence): boolean {
|
|
1477
|
-
const files = lifecycleMetadataReplayFiles(cleanup);
|
|
1478
|
-
if (!files) return true;
|
|
1479
|
-
const root = path.resolve(cleanup.metadataRoot!);
|
|
1480
|
-
const id = cleanup.sessionId!;
|
|
1481
|
-
const required = new Set<string>([lifecycleMarkerPath(root, id), lifecycleReadyPath(root, id)]);
|
|
1482
|
-
for (const file of files) for (const candidate of lifecycleCleanupCandidates(file)) required.add(candidate);
|
|
1483
|
-
for (const candidate of required) {
|
|
1484
|
-
if (absentLifecyclePath(candidate)) continue;
|
|
1485
|
-
// A completed file's recorded retained quarantine is durable evidence, not a
|
|
1486
|
-
// survivor — accept it only at its receipt-bound path and identity.
|
|
1487
|
-
const owner = files.find(
|
|
1488
|
-
file =>
|
|
1489
|
-
file.completed === true &&
|
|
1490
|
-
file.detachedPath !== undefined &&
|
|
1491
|
-
path.resolve(file.detachedPath) === path.resolve(candidate),
|
|
1492
|
-
);
|
|
1493
|
-
if (!owner) return false;
|
|
1494
|
-
const current = captureLifecycleFile(candidate, true, true);
|
|
1495
|
-
if (!current || !sameLifecycleCleanupIdentity(current.identity, owner.identity)) return false;
|
|
1496
|
-
}
|
|
1497
|
-
return true;
|
|
1498
|
-
}
|
|
1499
|
-
|
|
1500
1480
|
/**
|
|
1501
1481
|
* Base dev persisted metadata cleanup one file at a time. Accept only its
|
|
1502
1482
|
* identity-bound marker receipt and translate it into the current replay plan.
|
|
@@ -1612,7 +1592,7 @@ function legacyMetadataCleanupPlan(cleanup: CleanupEvidence): CleanupEvidence |
|
|
|
1612
1592
|
return undefined;
|
|
1613
1593
|
}
|
|
1614
1594
|
if (marker && !sameEffectMarker(marker, readyMarker)) return undefined;
|
|
1615
|
-
if (!marker && createHash("sha256").update(
|
|
1595
|
+
if (!marker && createHash("sha256").update(ready.capture.bytes).digest("hex") !== persistedIdentity.sha256)
|
|
1616
1596
|
return undefined;
|
|
1617
1597
|
}
|
|
1618
1598
|
|
|
@@ -1785,11 +1765,11 @@ async function reconcileLifecycleCleanup(
|
|
|
1785
1765
|
file.detachedPath &&
|
|
1786
1766
|
path.resolve(candidate) === path.resolve(file.detachedPath) &&
|
|
1787
1767
|
stat.isFile() &&
|
|
1788
|
-
!stat.isSymbolicLink()
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1768
|
+
!stat.isSymbolicLink() &&
|
|
1769
|
+
stat.nlink === 1 &&
|
|
1770
|
+
stat.size === 0
|
|
1771
|
+
)
|
|
1772
|
+
continue;
|
|
1793
1773
|
return fail(
|
|
1794
1774
|
"terminal_uncertain",
|
|
1795
1775
|
"Lifecycle cleanup receipt marks a target complete while an authorized candidate remains.",
|
|
@@ -1892,7 +1872,7 @@ async function reconcileLifecycleCleanup(
|
|
|
1892
1872
|
});
|
|
1893
1873
|
}
|
|
1894
1874
|
const lifecycleFiles = activeCleanup.lifecycleFiles!.map((candidate, candidateIndex) =>
|
|
1895
|
-
candidateIndex === index ? { ...candidate, completed: true as const } : candidate,
|
|
1875
|
+
candidateIndex === index ? { ...candidate, detachedPath: undefined, completed: true as const } : candidate,
|
|
1896
1876
|
);
|
|
1897
1877
|
activeCleanup = { ...activeCleanup, lifecycleFiles };
|
|
1898
1878
|
await broker.ledger.transition(identity, "effect_started", {
|
|
@@ -1900,8 +1880,6 @@ async function reconcileLifecycleCleanup(
|
|
|
1900
1880
|
});
|
|
1901
1881
|
lifecycleCleanupHooksForTest.get(broker)?.();
|
|
1902
1882
|
}
|
|
1903
|
-
if (!lifecycleMetadataReplayAbsent(activeCleanup))
|
|
1904
|
-
return fail("terminal_uncertain", "Lifecycle metadata replay left an authorized sibling behind.");
|
|
1905
1883
|
await syncDirectory(path.join(activeCleanup.metadataRoot!, "sdk"));
|
|
1906
1884
|
return completion;
|
|
1907
1885
|
}
|
|
@@ -3463,6 +3441,21 @@ async function executeLifecycleResponse(
|
|
|
3463
3441
|
return (error as NodeJS.ErrnoException).code === "ENOENT";
|
|
3464
3442
|
}
|
|
3465
3443
|
};
|
|
3444
|
+
try {
|
|
3445
|
+
const currentTranscript = fsSync.lstatSync(cleanupTarget.transcriptPath, { bigint: true });
|
|
3446
|
+
if (
|
|
3447
|
+
currentTranscript.isFile() &&
|
|
3448
|
+
!currentTranscript.isSymbolicLink() &&
|
|
3449
|
+
currentTranscript.dev === cleanupTarget.transcriptIdentity.dev &&
|
|
3450
|
+
currentTranscript.ino === cleanupTarget.transcriptIdentity.ino
|
|
3451
|
+
)
|
|
3452
|
+
cleanupTarget.transcriptIdentity = {
|
|
3453
|
+
...cleanupTarget.transcriptIdentity,
|
|
3454
|
+
nlink: currentTranscript.nlink,
|
|
3455
|
+
};
|
|
3456
|
+
} catch (error) {
|
|
3457
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
3458
|
+
}
|
|
3466
3459
|
const transcriptParentIdentity = cleanup?.transcriptParentIdentity ?? validated.transcriptParentIdentity;
|
|
3467
3460
|
const durableArtifactsPlan =
|
|
3468
3461
|
cleanup?.artifactTree?.plannedPath ?? cleanup?.plannedArtifactsPath ?? cleanupTarget.plannedArtifactsPath;
|
|
@@ -3687,11 +3680,17 @@ async function executeLifecycleResponse(
|
|
|
3687
3680
|
const retainedTranscriptReplayHasNoSideAuthority = retainedTranscriptSidePaths.every(
|
|
3688
3681
|
candidate => candidate === undefined,
|
|
3689
3682
|
);
|
|
3690
|
-
const retainedTranscriptSideAuthorityIsProvenAbsent =
|
|
3691
|
-
retainedTranscriptSidePaths.every(pathIsAbsent) &&
|
|
3692
|
-
(cleanupTarget.detachedTranscriptPath ? true : retainedTranscriptIdentityIsAbsentFromParent());
|
|
3693
3683
|
if (cleanup && !retainedTranscriptReplayHasNoSideAuthority) {
|
|
3694
|
-
|
|
3684
|
+
const successorOrUnknownRemains = [
|
|
3685
|
+
cleanupTarget.retainedTranscriptSuccessorPath,
|
|
3686
|
+
cleanupTarget.retainedTranscriptUnknownPath,
|
|
3687
|
+
].some(candidate => candidate !== undefined && !pathIsAbsent(candidate));
|
|
3688
|
+
if (
|
|
3689
|
+
successorOrUnknownRemains ||
|
|
3690
|
+
!pathIsAbsent(cleanupTarget.retainedTranscriptPlaceholderPath) ||
|
|
3691
|
+
(!cleanupTarget.detachedTranscriptPath && !retainedTranscriptIdentityIsAbsentFromParent())
|
|
3692
|
+
)
|
|
3693
|
+
return await publishRetainedTranscriptSideAuthority();
|
|
3695
3694
|
cleanupTarget.retainedTranscriptSuccessorPath = undefined;
|
|
3696
3695
|
cleanupTarget.retainedTranscriptPlaceholderPath = undefined;
|
|
3697
3696
|
cleanupTarget.retainedTranscriptUnknownPath = undefined;
|
|
@@ -3861,8 +3860,6 @@ async function executeLifecycleResponse(
|
|
|
3861
3860
|
preauthorizedCleanup,
|
|
3862
3861
|
"Saved session cleanup is pending in artifacts: a planned quarantine alias remains before terminal completion.",
|
|
3863
3862
|
);
|
|
3864
|
-
if (deleted.kind === "deleted" && !retainedTranscriptIdentityIsAbsentFromParent())
|
|
3865
|
-
return await publishRetainedTranscriptSideAuthority();
|
|
3866
3863
|
const retainedRootArtifactsPlan = durableArtifactsPlan;
|
|
3867
3864
|
if (deleted.kind === "cleanup_pending")
|
|
3868
3865
|
return fail(
|
|
@@ -11,10 +11,11 @@ import {
|
|
|
11
11
|
} from "./chat-daemon-control";
|
|
12
12
|
import { type ChatDaemonRuntimeConfig, ChatDaemonRuntime as DefaultChatDaemonRuntime } from "./chat-daemon-runtime";
|
|
13
13
|
import {
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
isDiscordComplete,
|
|
15
|
+
isSlackComplete,
|
|
16
16
|
loadNotificationConfigFile,
|
|
17
17
|
notificationConfigFromFile,
|
|
18
|
+
resolveNotificationProvider,
|
|
18
19
|
} from "./config";
|
|
19
20
|
|
|
20
21
|
export interface ChatDaemonRuntimeHandle {
|
|
@@ -60,7 +61,10 @@ async function loadConfig(agentDir: string, kind: ChatDaemonKind): Promise<ChatD
|
|
|
60
61
|
const config = notificationConfigFromFile(loaded.value);
|
|
61
62
|
if (!config.enabled) return undefined;
|
|
62
63
|
if (kind === "discord") {
|
|
63
|
-
|
|
64
|
+
const resolution = resolveNotificationProvider(config, "discord");
|
|
65
|
+
if (!resolution.desiredEnabled) return undefined;
|
|
66
|
+
if (resolution.quarantined) throw new Error("Discord notification configuration needs repair");
|
|
67
|
+
if (!resolution.configured || !isDiscordComplete(config)) {
|
|
64
68
|
throw new Error("Discord notifications are enabled but configuration is incomplete");
|
|
65
69
|
}
|
|
66
70
|
const discord = config.discord;
|
|
@@ -78,7 +82,10 @@ async function loadConfig(agentDir: string, kind: ChatDaemonKind): Promise<ChatD
|
|
|
78
82
|
presentation: { redact: config.redact, verbosity: config.verbosity },
|
|
79
83
|
};
|
|
80
84
|
}
|
|
81
|
-
|
|
85
|
+
const resolution = resolveNotificationProvider(config, "slack");
|
|
86
|
+
if (!resolution.desiredEnabled) return undefined;
|
|
87
|
+
if (resolution.quarantined) throw new Error("Slack notification configuration needs repair");
|
|
88
|
+
if (!resolution.configured || !isSlackComplete(config)) {
|
|
82
89
|
throw new Error("Slack notifications are enabled but configuration is incomplete");
|
|
83
90
|
}
|
|
84
91
|
const slack = config.slack;
|
|
@@ -149,7 +156,7 @@ export async function runChatDaemonInternal(
|
|
|
149
156
|
|
|
150
157
|
let incarnation: string | undefined;
|
|
151
158
|
let runtime: ChatDaemonRuntimeHandle | undefined;
|
|
152
|
-
let interval:
|
|
159
|
+
let interval: NodeJS.Timeout | number | undefined;
|
|
153
160
|
let stopping = false;
|
|
154
161
|
let terminalError: unknown;
|
|
155
162
|
let runtimeStop: Promise<void> | undefined;
|
|
@@ -16,7 +16,7 @@ import type {
|
|
|
16
16
|
} from "../../daemon/control-types";
|
|
17
17
|
import { resolveGjcRuntimeSpawnInfo } from "../../daemon/runtime";
|
|
18
18
|
import { isProcessIncarnation, processIncarnation } from "../broker/process-incarnation";
|
|
19
|
-
import { getNotificationConfig,
|
|
19
|
+
import { getNotificationConfig, isDiscordComplete, isProviderEffectivelyEnabled, isSlackComplete } from "./config";
|
|
20
20
|
|
|
21
21
|
export type ChatDaemonKind = "discord" | "slack";
|
|
22
22
|
export type ChatDaemonAction = "stop" | "reload";
|
|
@@ -40,11 +40,20 @@ export type ChatDaemonAction = "stop" | "reload";
|
|
|
40
40
|
* daemon transports. Generation 16 applies Telegram sound-policy configuration
|
|
41
41
|
* through shared notification parsing. Generation 17 bound managed-session
|
|
42
42
|
* replacement to exact native filesystem authority; generation 18 retired that
|
|
43
|
-
* binding, and generation 19 binds exact cleanup to parent/link-count authority
|
|
43
|
+
* binding, and generation 19 binds exact cleanup to parent/link-count authority
|
|
44
|
+
* while also adding durable provider-intent admission without changing lifecycle
|
|
45
|
+
* behavior. Generation 20 discovers isolated chat-only session endpoints when
|
|
46
|
+
* Telegram identity ownership is blocked. Discord generation 21 applies rustfmt
|
|
47
|
+
* and clippy-equivalent cleanup to the pi-shell process-tree authority (#3682).
|
|
48
|
+
* Discord generation 22 / slack generation 21 refreshes retained cleanup
|
|
49
|
+
* semantics; discord generation 23 / slack generation 22 hardens exact Bash
|
|
50
|
+
* process-tree ownership shared by chat daemon cleanup.
|
|
51
|
+
* Discord generation 24 / slack generation 23 apply provider-completeness and
|
|
52
|
+
* effective-enable admission to chat daemon lifecycle controls.
|
|
44
53
|
*/
|
|
45
54
|
export const CHAT_DAEMON_GENERATIONS: Readonly<Record<ChatDaemonKind, number>> = {
|
|
46
|
-
discord:
|
|
47
|
-
slack:
|
|
55
|
+
discord: 24,
|
|
56
|
+
slack: 23,
|
|
48
57
|
};
|
|
49
58
|
|
|
50
59
|
export function chatDaemonGeneration(kind: ChatDaemonKind): number {
|
|
@@ -271,7 +280,7 @@ export function chatDaemonPaths(
|
|
|
271
280
|
function identityFor(settings: Settings, kind: ChatDaemonKind): string | undefined {
|
|
272
281
|
const cfg = getNotificationConfig(settings);
|
|
273
282
|
if (kind === "discord") {
|
|
274
|
-
if (!
|
|
283
|
+
if (!isDiscordComplete(cfg)) return undefined;
|
|
275
284
|
return fingerprint([
|
|
276
285
|
cfg.discord.botToken,
|
|
277
286
|
cfg.discord.applicationId,
|
|
@@ -281,7 +290,7 @@ function identityFor(settings: Settings, kind: ChatDaemonKind): string | undefin
|
|
|
281
290
|
cfg.verbosity,
|
|
282
291
|
]);
|
|
283
292
|
}
|
|
284
|
-
if (!
|
|
293
|
+
if (!isSlackComplete(cfg)) return undefined;
|
|
285
294
|
return fingerprint([
|
|
286
295
|
cfg.slack.botToken,
|
|
287
296
|
cfg.slack.appToken,
|
|
@@ -426,6 +435,9 @@ export class ChatDaemonController implements BuiltInDaemonController {
|
|
|
426
435
|
private identity(): string | undefined {
|
|
427
436
|
return identityFor(this.settings, this.kind);
|
|
428
437
|
}
|
|
438
|
+
private effectivelyEnabled(): boolean {
|
|
439
|
+
return isProviderEffectivelyEnabled(getNotificationConfig(this.settings), this.kind);
|
|
440
|
+
}
|
|
429
441
|
private alive(pid: number): boolean {
|
|
430
442
|
return (this.deps.pidAlive ?? defaultPidAlive)(pid);
|
|
431
443
|
}
|
|
@@ -453,6 +465,7 @@ export class ChatDaemonController implements BuiltInDaemonController {
|
|
|
453
465
|
return await this.operate("reload", opts);
|
|
454
466
|
}
|
|
455
467
|
async ensure(): Promise<EnsureChatDaemonResult> {
|
|
468
|
+
if (!this.effectivelyEnabled()) return "disabled";
|
|
456
469
|
const identity = this.identity();
|
|
457
470
|
if (!identity) return "disabled";
|
|
458
471
|
const existing = await readChatDaemonState(this.settings.getAgentDir(), this.kind);
|
|
@@ -485,6 +498,9 @@ export class ChatDaemonController implements BuiltInDaemonController {
|
|
|
485
498
|
const warnings = before.runtime.warning ? [before.runtime.warning] : [];
|
|
486
499
|
if (!before.configured)
|
|
487
500
|
return this.result(action, false, `${this.kind} notifications are not configured`, before, before, warnings);
|
|
501
|
+
if (action === "reload" && !this.effectivelyEnabled()) {
|
|
502
|
+
return this.result(action, false, `${this.kind} notifications are not enabled`, before, before, warnings);
|
|
503
|
+
}
|
|
488
504
|
const state = await readChatDaemonState(this.settings.getAgentDir(), this.kind);
|
|
489
505
|
const classification = this.classify(state, this.identity());
|
|
490
506
|
if (classification === "newer")
|
|
@@ -520,6 +536,8 @@ export class ChatDaemonController implements BuiltInDaemonController {
|
|
|
520
536
|
}
|
|
521
537
|
if (opts.spawnIfStopped === false)
|
|
522
538
|
return this.result(action, true, `no running ${this.kind} daemon to reload`, before, before, warnings);
|
|
539
|
+
if (!this.effectivelyEnabled())
|
|
540
|
+
return this.result(action, false, `${this.kind} notifications are not enabled`, before, before, warnings);
|
|
523
541
|
const spawned = await this.spawn();
|
|
524
542
|
return this.result(
|
|
525
543
|
action,
|
|
@@ -573,6 +591,15 @@ export class ChatDaemonController implements BuiltInDaemonController {
|
|
|
573
591
|
await clearChatDaemonControlRequest(this.settings.getAgentDir(), this.kind, requestId);
|
|
574
592
|
if (action === "stop")
|
|
575
593
|
return this.result(action, true, `stopped ${this.kind} daemon`, before, await this.status(), warnings);
|
|
594
|
+
if (!this.effectivelyEnabled())
|
|
595
|
+
return this.result(
|
|
596
|
+
action,
|
|
597
|
+
false,
|
|
598
|
+
`${this.kind} notifications are not enabled`,
|
|
599
|
+
before,
|
|
600
|
+
await this.status(),
|
|
601
|
+
warnings,
|
|
602
|
+
);
|
|
576
603
|
const spawned = await this.spawn();
|
|
577
604
|
return this.result(
|
|
578
605
|
action,
|
|
@@ -750,6 +777,7 @@ export class ChatDaemonController implements BuiltInDaemonController {
|
|
|
750
777
|
return this.deps.sleep ? this.deps.sleep(ms) : new Promise(resolve => setTimeout(resolve, ms));
|
|
751
778
|
}
|
|
752
779
|
private async spawn(): Promise<boolean> {
|
|
780
|
+
if (!this.effectivelyEnabled()) return false;
|
|
753
781
|
const identity = this.identity();
|
|
754
782
|
if (!identity) return false;
|
|
755
783
|
const paths = chatDaemonPaths(this.settings.getAgentDir(), this.kind);
|
|
@@ -766,6 +794,7 @@ export class ChatDaemonController implements BuiltInDaemonController {
|
|
|
766
794
|
agentDir: this.settings.getAgentDir(),
|
|
767
795
|
execPath: this.deps.execPath,
|
|
768
796
|
});
|
|
797
|
+
if (!this.effectivelyEnabled() || this.identity() !== identity) return false;
|
|
769
798
|
(this.deps.spawn ?? ((command, args, opts) => childProcessSpawn(command, args, opts)))(command, args, {
|
|
770
799
|
detached: true,
|
|
771
800
|
stdio: "ignore",
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
+
import * as fs from "node:fs/promises";
|
|
3
|
+
import * as path from "node:path";
|
|
2
4
|
import { type IndexedSession, SessionIndex } from "../broker/session-index";
|
|
3
5
|
import { SdkClient, SdkClientError } from "../client/client";
|
|
4
6
|
import { readSdkBrokerDiscovery, readSdkSessionEndpoint, type SdkSessionEndpoint } from "../client/discovery";
|
|
@@ -275,8 +277,20 @@ export class ChatDaemonRuntime {
|
|
|
275
277
|
}
|
|
276
278
|
|
|
277
279
|
private async attach(indexed: IndexedSession): Promise<void> {
|
|
278
|
-
const
|
|
279
|
-
|
|
280
|
+
const repo = path.resolve(indexed.locator.repo);
|
|
281
|
+
const defaultStateRoot = path.join(repo, ".gjc", "state");
|
|
282
|
+
const indexedStateRoot = path.resolve(indexed.locator.stateRoot);
|
|
283
|
+
const scope =
|
|
284
|
+
indexedStateRoot === defaultStateRoot
|
|
285
|
+
? "default"
|
|
286
|
+
: indexedStateRoot === path.join(defaultStateRoot, "chat")
|
|
287
|
+
? "chat"
|
|
288
|
+
: undefined;
|
|
289
|
+
if (!scope || indexed.endpointMtimeMs === undefined) return;
|
|
290
|
+
const endpoint = await readSdkSessionEndpoint(repo, indexed.sessionId, scope);
|
|
291
|
+
if (!endpoint || endpoint.stale) return;
|
|
292
|
+
const endpointStat = await fs.stat(endpoint.path).catch(() => undefined);
|
|
293
|
+
if (!endpointStat || endpointStat.mtimeMs !== indexed.endpointMtimeMs) return;
|
|
280
294
|
const existing = this.#sessions.get(indexed.sessionId);
|
|
281
295
|
if (
|
|
282
296
|
existing &&
|