@gajae-code/coding-agent 0.4.5 → 0.5.1
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 +62 -0
- package/dist/types/async/job-manager.d.ts +26 -0
- package/dist/types/cli/args.d.ts +1 -0
- package/dist/types/cli/list-models.d.ts +6 -0
- package/dist/types/commands/gc.d.ts +26 -0
- package/dist/types/commands/harness.d.ts +3 -0
- package/dist/types/config/file-lock-gc.d.ts +5 -0
- package/dist/types/config/file-lock.d.ts +7 -0
- package/dist/types/config/model-profile-activation.d.ts +11 -2
- package/dist/types/config/model-profiles.d.ts +7 -0
- package/dist/types/config/model-registry.d.ts +3 -0
- package/dist/types/config/model-resolver.d.ts +2 -0
- package/dist/types/config/models-config-schema.d.ts +30 -0
- package/dist/types/config/settings-schema.d.ts +4 -3
- package/dist/types/coordinator/contract.d.ts +1 -1
- package/dist/types/defaults/gjc/extensions/grok-build/index.d.ts +1 -0
- package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/index.d.ts +1 -0
- package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.d.ts +25 -0
- package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/payload/sanitize.d.ts +27 -0
- package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/billing.d.ts +8 -0
- package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/register.d.ts +5 -0
- package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/stream.d.ts +10 -0
- package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/usage.d.ts +2 -0
- package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/shared/base-url.d.ts +2 -0
- package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/shared/errors.d.ts +38 -0
- package/dist/types/defaults/gjc-grok-cli.d.ts +5 -0
- package/dist/types/extensibility/extensions/index.d.ts +1 -0
- package/dist/types/extensibility/extensions/prefix-command-bridge.d.ts +35 -0
- package/dist/types/gjc-runtime/deep-interview-recorder.d.ts +103 -0
- package/dist/types/gjc-runtime/deep-interview-runtime.d.ts +2 -0
- package/dist/types/gjc-runtime/deep-interview-state.d.ts +112 -0
- package/dist/types/gjc-runtime/gc-render.d.ts +6 -0
- package/dist/types/gjc-runtime/gc-runtime.d.ts +134 -0
- package/dist/types/gjc-runtime/ledger-event-renderer.d.ts +68 -0
- package/dist/types/gjc-runtime/team-gc.d.ts +7 -0
- package/dist/types/gjc-runtime/team-runtime.d.ts +5 -1
- package/dist/types/gjc-runtime/tmux-common.d.ts +14 -0
- package/dist/types/gjc-runtime/tmux-gc.d.ts +7 -0
- package/dist/types/gjc-runtime/tmux-sessions.d.ts +13 -0
- package/dist/types/harness-control-plane/gc-adapter.d.ts +3 -0
- package/dist/types/harness-control-plane/owner.d.ts +8 -1
- package/dist/types/harness-control-plane/receipt-spool.d.ts +19 -0
- package/dist/types/harness-control-plane/state-machine.d.ts +6 -1
- package/dist/types/harness-control-plane/storage.d.ts +20 -0
- package/dist/types/harness-control-plane/types.d.ts +4 -0
- package/dist/types/hindsight/mental-models.d.ts +5 -5
- package/dist/types/modes/components/hook-selector.d.ts +7 -1
- package/dist/types/modes/components/model-selector.d.ts +1 -12
- package/dist/types/modes/controllers/command-controller.d.ts +1 -0
- package/dist/types/modes/rpc/rpc-client.d.ts +2 -2
- package/dist/types/modes/rpc/rpc-mode.d.ts +16 -1
- package/dist/types/modes/rpc/rpc-types.d.ts +4 -1
- package/dist/types/modes/shared/agent-wire/deep-interview-gate.d.ts +13 -0
- package/dist/types/modes/shared/agent-wire/session-registry.d.ts +25 -0
- package/dist/types/modes/shared/agent-wire/unattended-action-policy.d.ts +2 -0
- package/dist/types/sdk.d.ts +5 -0
- package/dist/types/session/agent-session.d.ts +3 -1
- package/dist/types/session/blob-store.d.ts +59 -4
- package/dist/types/session/session-manager.d.ts +24 -6
- package/dist/types/session/streaming-output.d.ts +3 -2
- package/dist/types/session/tool-choice-queue.d.ts +6 -0
- package/dist/types/skill-state/workflow-hud.d.ts +14 -0
- package/dist/types/task/receipt.d.ts +1 -0
- package/dist/types/task/types.d.ts +7 -0
- package/dist/types/thinking-metadata.d.ts +16 -0
- package/dist/types/thinking.d.ts +3 -12
- package/dist/types/tools/ask.d.ts +15 -1
- package/dist/types/tools/index.d.ts +2 -0
- package/dist/types/tools/resolve.d.ts +0 -10
- package/dist/types/tools/subagent.d.ts +6 -0
- package/dist/types/utils/tool-choice.d.ts +14 -1
- package/package.json +7 -7
- package/src/async/job-manager.ts +52 -0
- package/src/cli/args.ts +3 -0
- package/src/cli/auth-broker-cli.ts +1 -0
- package/src/cli/list-models.ts +13 -1
- package/src/cli.ts +9 -4
- package/src/commands/gc.ts +22 -0
- package/src/commands/harness.ts +43 -5
- package/src/commands/launch.ts +2 -2
- package/src/commands/session.ts +3 -1
- package/src/config/file-lock-gc.ts +181 -0
- package/src/config/file-lock.ts +14 -0
- package/src/config/model-profile-activation.ts +15 -3
- package/src/config/model-profiles.ts +264 -56
- package/src/config/model-resolver.ts +9 -6
- package/src/config/models-config-schema.ts +1 -0
- package/src/config/settings-schema.ts +6 -3
- package/src/coordinator/contract.ts +1 -0
- package/src/coordinator-mcp/server.ts +513 -26
- package/src/cursor.ts +16 -2
- package/src/defaults/gjc/agent.models.grok-cli.yml +36 -0
- package/src/defaults/gjc/extensions/grok-build/index.ts +1 -0
- package/src/defaults/gjc/extensions/grok-build/package.json +7 -0
- package/src/defaults/gjc/extensions/grok-cli-vendor/biome.json +39 -0
- package/src/defaults/gjc/extensions/grok-cli-vendor/package.json +8 -0
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/index.ts +1 -0
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.ts +155 -0
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/payload/sanitize.ts +361 -0
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/billing.ts +57 -0
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/register.ts +99 -0
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/stream.ts +50 -0
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/usage.ts +56 -0
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/shared/base-url.ts +36 -0
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/shared/errors.ts +44 -0
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +131 -113
- package/src/defaults/gjc/skills/deep-interview/lateral-review-panel.md +49 -0
- package/src/defaults/gjc/skills/team/SKILL.md +3 -2
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +8 -2
- package/src/defaults/gjc-defaults.ts +7 -0
- package/src/defaults/gjc-grok-cli.ts +22 -0
- package/src/export/html/index.ts +13 -9
- package/src/extensibility/extensions/index.ts +1 -0
- package/src/extensibility/extensions/prefix-command-bridge.ts +128 -0
- package/src/gjc-runtime/deep-interview-recorder.ts +417 -0
- package/src/gjc-runtime/deep-interview-runtime.ts +18 -26
- package/src/gjc-runtime/deep-interview-state.ts +324 -0
- package/src/gjc-runtime/gc-render.ts +70 -0
- package/src/gjc-runtime/gc-runtime.ts +403 -0
- package/src/gjc-runtime/ledger-event-renderer.ts +164 -0
- package/src/gjc-runtime/ralplan-runtime.ts +58 -7
- package/src/gjc-runtime/state-renderer.ts +12 -3
- package/src/gjc-runtime/state-runtime.ts +46 -29
- package/src/gjc-runtime/team-gc.ts +49 -0
- package/src/gjc-runtime/team-runtime.ts +211 -8
- package/src/gjc-runtime/tmux-common.ts +29 -0
- package/src/gjc-runtime/tmux-gc.ts +176 -0
- package/src/gjc-runtime/tmux-sessions.ts +68 -12
- package/src/gjc-runtime/ultragoal-runtime.ts +517 -41
- package/src/gjc-runtime/workflow-manifest.generated.json +27 -1
- package/src/gjc-runtime/workflow-manifest.ts +16 -1
- package/src/harness-control-plane/gc-adapter.ts +184 -0
- package/src/harness-control-plane/owner.ts +89 -27
- package/src/harness-control-plane/receipt-spool.ts +128 -0
- package/src/harness-control-plane/state-machine.ts +27 -6
- package/src/harness-control-plane/storage.ts +93 -0
- package/src/harness-control-plane/types.ts +4 -0
- package/src/hindsight/mental-models.ts +17 -16
- package/src/internal-urls/docs-index.generated.ts +14 -8
- package/src/main.ts +7 -2
- package/src/modes/components/assistant-message.ts +26 -14
- package/src/modes/components/diff.ts +97 -0
- package/src/modes/components/hook-selector.ts +19 -0
- package/src/modes/components/model-selector.ts +370 -181
- package/src/modes/components/status-line/segments.ts +1 -1
- package/src/modes/components/tool-execution.ts +30 -13
- package/src/modes/controllers/command-controller.ts +25 -6
- package/src/modes/controllers/extension-ui-controller.ts +3 -0
- package/src/modes/controllers/selector-controller.ts +34 -42
- package/src/modes/rpc/rpc-client.ts +3 -2
- package/src/modes/rpc/rpc-mode.ts +187 -39
- package/src/modes/rpc/rpc-types.ts +5 -2
- package/src/modes/shared/agent-wire/command-dispatch.ts +279 -257
- package/src/modes/shared/agent-wire/command-validation.ts +11 -0
- package/src/modes/shared/agent-wire/deep-interview-gate.ts +30 -1
- package/src/modes/shared/agent-wire/session-registry.ts +109 -0
- package/src/modes/shared/agent-wire/unattended-action-policy.ts +24 -0
- package/src/modes/shared/agent-wire/unattended-run-controller.ts +23 -3
- package/src/modes/shared/agent-wire/unattended-session.ts +16 -1
- package/src/sdk.ts +46 -5
- package/src/secrets/obfuscator.ts +102 -27
- package/src/session/agent-session.ts +179 -25
- package/src/session/blob-store.ts +148 -6
- package/src/session/session-manager.ts +311 -60
- package/src/session/streaming-output.ts +185 -122
- package/src/session/tool-choice-queue.ts +23 -0
- package/src/setup/hermes/templates/operator-instructions.v1.md +7 -1
- package/src/skill-state/workflow-hud.ts +106 -10
- package/src/slash-commands/builtin-registry.ts +3 -2
- package/src/task/executor.ts +78 -6
- package/src/task/receipt.ts +5 -0
- package/src/task/render.ts +21 -1
- package/src/task/types.ts +8 -0
- package/src/thinking-metadata.ts +51 -0
- package/src/thinking.ts +26 -46
- package/src/tools/ask.ts +56 -1
- package/src/tools/bash.ts +1 -1
- package/src/tools/index.ts +2 -0
- package/src/tools/job.ts +3 -2
- package/src/tools/monitor.ts +36 -1
- package/src/tools/resolve.ts +93 -18
- package/src/tools/subagent-render.ts +9 -0
- package/src/tools/subagent.ts +26 -2
- package/src/utils/edit-mode.ts +1 -1
- package/src/utils/tool-choice.ts +45 -16
|
@@ -11,10 +11,10 @@ import {
|
|
|
11
11
|
} from "../skill-state/active-state";
|
|
12
12
|
import { initialPhaseForSkill } from "../skill-state/initial-phase";
|
|
13
13
|
import {
|
|
14
|
-
buildDeepInterviewHudSummary,
|
|
15
14
|
buildRalplanHudSummary,
|
|
16
15
|
buildTeamHudSummary,
|
|
17
16
|
buildUltragoalHudSummary,
|
|
17
|
+
deriveDeepInterviewHud,
|
|
18
18
|
} from "../skill-state/workflow-hud";
|
|
19
19
|
import {
|
|
20
20
|
type AuditEntry,
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
type WorkflowStateReceipt,
|
|
27
27
|
} from "../skill-state/workflow-state-contract";
|
|
28
28
|
import { renderCliWriteReceipt } from "./cli-write-receipt";
|
|
29
|
+
import { mergeDeepInterviewEnvelope, normalizeDeepInterviewEnvelope } from "./deep-interview-state";
|
|
29
30
|
import { renderStateGraph, type StateGraphFormat } from "./state-graph";
|
|
30
31
|
import { migrateAndPersistLegacyState, migrateWorkflowState } from "./state-migrations";
|
|
31
32
|
import {
|
|
@@ -833,31 +834,9 @@ function buildHudForMode(
|
|
|
833
834
|
): WorkflowHudSummary | undefined {
|
|
834
835
|
const updatedAt = new Date().toISOString();
|
|
835
836
|
const phase = typeof payload.current_phase === "string" ? payload.current_phase : undefined;
|
|
836
|
-
const stateField = isPlainObject(payload.state) ? (payload.state as Record<string, unknown>) : {};
|
|
837
837
|
switch (mode) {
|
|
838
|
-
case "deep-interview":
|
|
839
|
-
|
|
840
|
-
const v = (stateField as Record<string, unknown>)[key] ?? (payload as Record<string, unknown>)[key];
|
|
841
|
-
return guard(v) ? v : undefined;
|
|
842
|
-
};
|
|
843
|
-
const isNumber = (v: unknown): v is number => typeof v === "number";
|
|
844
|
-
const isString = (v: unknown): v is string => typeof v === "string";
|
|
845
|
-
const isArray = (v: unknown): v is unknown[] => Array.isArray(v);
|
|
846
|
-
const ambiguity = pick("current_ambiguity", isNumber);
|
|
847
|
-
const threshold = pick("threshold", isNumber);
|
|
848
|
-
const rounds = pick("rounds", isArray);
|
|
849
|
-
const targetComponent = pick("last_targeted_component_id", isString);
|
|
850
|
-
const weakestDimension = pick("weakest_dimension", isString);
|
|
851
|
-
return buildDeepInterviewHudSummary({
|
|
852
|
-
phase,
|
|
853
|
-
ambiguity,
|
|
854
|
-
threshold,
|
|
855
|
-
roundCount: rounds?.length,
|
|
856
|
-
targetComponent,
|
|
857
|
-
weakestDimension,
|
|
858
|
-
updatedAt,
|
|
859
|
-
});
|
|
860
|
-
}
|
|
838
|
+
case "deep-interview":
|
|
839
|
+
return deriveDeepInterviewHud(payload, { updatedAt });
|
|
861
840
|
case "ralplan": {
|
|
862
841
|
const stage =
|
|
863
842
|
typeof payload.current_phase === "string"
|
|
@@ -888,6 +867,24 @@ function buildHudForMode(
|
|
|
888
867
|
counts[status] = (counts[status] ?? 0) + 1;
|
|
889
868
|
}
|
|
890
869
|
const currentGoalRaw = goals.find(g => g.status === "active") ?? goals.find(g => g.status === "pending");
|
|
870
|
+
const rawLedger = payload.latestLedgerEvent;
|
|
871
|
+
const latestLedgerEvent =
|
|
872
|
+
rawLedger && typeof rawLedger === "object" && !Array.isArray(rawLedger)
|
|
873
|
+
? {
|
|
874
|
+
event:
|
|
875
|
+
typeof (rawLedger as Record<string, unknown>).event === "string"
|
|
876
|
+
? ((rawLedger as Record<string, unknown>).event as string)
|
|
877
|
+
: undefined,
|
|
878
|
+
goalId:
|
|
879
|
+
typeof (rawLedger as Record<string, unknown>).goalId === "string"
|
|
880
|
+
? ((rawLedger as Record<string, unknown>).goalId as string)
|
|
881
|
+
: undefined,
|
|
882
|
+
timestamp:
|
|
883
|
+
typeof (rawLedger as Record<string, unknown>).timestamp === "string"
|
|
884
|
+
? ((rawLedger as Record<string, unknown>).timestamp as string)
|
|
885
|
+
: undefined,
|
|
886
|
+
}
|
|
887
|
+
: undefined;
|
|
891
888
|
const status = typeof payload.status === "string" ? (payload.status as string) : (phase ?? "pending");
|
|
892
889
|
return buildUltragoalHudSummary({
|
|
893
890
|
status,
|
|
@@ -900,6 +897,7 @@ function buildHudForMode(
|
|
|
900
897
|
: undefined,
|
|
901
898
|
counts,
|
|
902
899
|
goals: goals.map(g => ({ id: g.id as string, title: g.title as string, status: g.status as string })),
|
|
900
|
+
latestLedgerEvent,
|
|
903
901
|
updatedAt,
|
|
904
902
|
});
|
|
905
903
|
}
|
|
@@ -1009,7 +1007,10 @@ export async function reconcileWorkflowSkillState(options: {
|
|
|
1009
1007
|
receipt.from_phase = fromPhase;
|
|
1010
1008
|
receipt.to_phase = trimmedPhase;
|
|
1011
1009
|
|
|
1012
|
-
const merged =
|
|
1010
|
+
const merged =
|
|
1011
|
+
mode === "deep-interview"
|
|
1012
|
+
? (mergeDeepInterviewEnvelope(existingPayload, payload) as Record<string, unknown>)
|
|
1013
|
+
: mergeWithNullDelete(existingPayload, payload);
|
|
1013
1014
|
merged.skill = mode;
|
|
1014
1015
|
merged.current_phase = trimmedPhase;
|
|
1015
1016
|
merged.active = active;
|
|
@@ -1173,7 +1174,11 @@ async function handleWrite(
|
|
|
1173
1174
|
? (innerState.current_phase as string).trim()
|
|
1174
1175
|
: undefined;
|
|
1175
1176
|
let merged: Record<string, unknown>;
|
|
1176
|
-
if (
|
|
1177
|
+
if (mode === "deep-interview") {
|
|
1178
|
+
// Deep-interview keeps interview data nested under `state` and merges rounds
|
|
1179
|
+
// losslessly by durable key; never flatten or delete `state` (that drops recorder history).
|
|
1180
|
+
merged = mergeDeepInterviewEnvelope(existingPayload, payload, { replace: hasFlag(args, "--replace") });
|
|
1181
|
+
} else if (hasFlag(args, "--replace")) {
|
|
1177
1182
|
merged = { ...payload };
|
|
1178
1183
|
} else {
|
|
1179
1184
|
merged = mergeWithNullDelete(existingPayload, payload);
|
|
@@ -1423,8 +1428,20 @@ async function handleHandoff(
|
|
|
1423
1428
|
});
|
|
1424
1429
|
|
|
1425
1430
|
const calleeInitial = initialPhaseForSkill(callee);
|
|
1426
|
-
const normalizedCaller =
|
|
1427
|
-
|
|
1431
|
+
const normalizedCaller =
|
|
1432
|
+
caller === "deep-interview"
|
|
1433
|
+
? (normalizeDeepInterviewEnvelope(migrateWorkflowState(existingCaller, caller).state) as Record<
|
|
1434
|
+
string,
|
|
1435
|
+
unknown
|
|
1436
|
+
>)
|
|
1437
|
+
: migrateWorkflowState(existingCaller, caller).state;
|
|
1438
|
+
const normalizedCallee =
|
|
1439
|
+
callee === "deep-interview"
|
|
1440
|
+
? (normalizeDeepInterviewEnvelope(migrateWorkflowState(existingCallee, callee).state) as Record<
|
|
1441
|
+
string,
|
|
1442
|
+
unknown
|
|
1443
|
+
>)
|
|
1444
|
+
: migrateWorkflowState(existingCallee, callee).state;
|
|
1428
1445
|
const mergedCalleeState: Record<string, unknown> = {
|
|
1429
1446
|
...normalizedCallee,
|
|
1430
1447
|
skill: callee,
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GC adapter for team workers (`.gjc/state/team/<name>/workers/<id>/` heartbeat
|
|
3
|
+
* + lifecycle). Liveness-only: numeric PID status dominates lifecycle/heartbeat
|
|
4
|
+
* signals.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as path from "node:path";
|
|
8
|
+
import { listHarnessRootRegistriesForGc } from "../harness-control-plane/storage";
|
|
9
|
+
import type { GcCollectResult, GcContext, GcPruneOutcome, GcRecord, GcStoreAdapter } from "./gc-runtime";
|
|
10
|
+
import { listTeamWorkerGcRecords, pruneTeamWorkerGcRecord } from "./team-runtime";
|
|
11
|
+
|
|
12
|
+
function uniqueTeamRootsFromHarnessRoots(roots: string[]): string[] {
|
|
13
|
+
return [...new Set(roots.map(root => path.join(path.dirname(root), "team")))].sort();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const teamWorkersGcAdapter: GcStoreAdapter = {
|
|
17
|
+
store: "team_workers",
|
|
18
|
+
async collect(ctx: GcContext): Promise<GcCollectResult> {
|
|
19
|
+
const records: GcRecord[] = [];
|
|
20
|
+
const errors: GcCollectResult["errors"] = [];
|
|
21
|
+
const registries = await listHarnessRootRegistriesForGc(ctx.env);
|
|
22
|
+
for (const registry of registries) {
|
|
23
|
+
if (registry.error) errors.push({ store: "team_workers", scope: registry.file, message: registry.error });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const teamRoots = uniqueTeamRootsFromHarnessRoots(
|
|
27
|
+
registries.flatMap(registry => registry.roots.map(entry => entry.root)),
|
|
28
|
+
);
|
|
29
|
+
for (const teamRoot of teamRoots) {
|
|
30
|
+
try {
|
|
31
|
+
records.push(...(await listTeamWorkerGcRecords(teamRoot, ctx.probe)));
|
|
32
|
+
} catch (error) {
|
|
33
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
34
|
+
if (code === "ENOENT") continue;
|
|
35
|
+
errors.push({ store: "team_workers", scope: teamRoot, message: (error as Error).message });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return { records, errors };
|
|
40
|
+
},
|
|
41
|
+
async prune(record: GcRecord, ctx: GcContext): Promise<GcPruneOutcome> {
|
|
42
|
+
try {
|
|
43
|
+
const removed = await pruneTeamWorkerGcRecord(record, ctx.probe);
|
|
44
|
+
return removed ? { removed: true } : { removed: false, skipped: "worker_no_longer_dead" };
|
|
45
|
+
} catch (error) {
|
|
46
|
+
return { removed: false, error: (error as Error).message };
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
};
|
|
@@ -4,8 +4,9 @@ import * as path from "node:path";
|
|
|
4
4
|
import type { WorkflowHudSummary } from "../skill-state/active-state";
|
|
5
5
|
import { buildTeamHudSummary as buildWorkflowTeamHudSummary } from "../skill-state/workflow-hud";
|
|
6
6
|
import { WORKFLOW_STATE_VERSION } from "../skill-state/workflow-state-contract";
|
|
7
|
+
import type { GcPidProbe, GcRecord } from "./gc-runtime";
|
|
7
8
|
|
|
8
|
-
import { applyGjcTmuxProfile } from "./launch-tmux";
|
|
9
|
+
import { applyGjcTmuxProfile, GJC_TMUX_LAUNCHED_ENV } from "./launch-tmux";
|
|
9
10
|
import {
|
|
10
11
|
AlreadyExistsError,
|
|
11
12
|
appendJsonl as appendJsonlAudited,
|
|
@@ -17,7 +18,13 @@ import {
|
|
|
17
18
|
writeReport,
|
|
18
19
|
writeWorkflowEnvelopeAtomic,
|
|
19
20
|
} from "./state-writer";
|
|
20
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
buildGjcTmuxExactOptionTarget,
|
|
23
|
+
buildGjcTmuxUntaggedSessionHint,
|
|
24
|
+
GJC_TMUX_PROFILE_OPTION,
|
|
25
|
+
GJC_TMUX_PROFILE_VALUE,
|
|
26
|
+
resolveGjcTmuxCommand,
|
|
27
|
+
} from "./tmux-common";
|
|
21
28
|
|
|
22
29
|
export type GjcTeamPhase = "starting" | "running" | "awaiting_integration" | "complete" | "failed" | "cancelled";
|
|
23
30
|
export type GjcTeamTaskStatus = "pending" | "blocked" | "in_progress" | "completed" | "failed";
|
|
@@ -672,6 +679,174 @@ async function readJsonFile<T>(filePath: string): Promise<T | null> {
|
|
|
672
679
|
throw error;
|
|
673
680
|
}
|
|
674
681
|
}
|
|
682
|
+
function isPositivePid(value: unknown): value is number {
|
|
683
|
+
return typeof value === "number" && Number.isInteger(value) && value > 0;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function collectTeamGcWorkerPids(
|
|
687
|
+
heartbeat: WorkerHeartbeatFile | null,
|
|
688
|
+
lifecycle: GjcTeamWorkerLifecycle | null,
|
|
689
|
+
): number[] {
|
|
690
|
+
const pids: number[] = [];
|
|
691
|
+
if (isPositivePid(heartbeat?.pid)) pids.push(heartbeat.pid);
|
|
692
|
+
if (isPositivePid(lifecycle?.pid) && !pids.includes(lifecycle.pid)) pids.push(lifecycle.pid);
|
|
693
|
+
return pids;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
interface TeamGcPidClassification {
|
|
697
|
+
removable: boolean;
|
|
698
|
+
pidStatus: "dead" | "alive" | "eperm" | "unknown" | "none";
|
|
699
|
+
pid?: number;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* Liveness-only, fail-closed: a worker is removable ONLY when it has at least
|
|
704
|
+
* one authoritative pid and EVERY candidate pid probes dead (ESRCH). Any alive,
|
|
705
|
+
* EPERM, or unknown candidate (heartbeat OR lifecycle) keeps the worker, so a
|
|
706
|
+
* dead heartbeat pid can never override a live lifecycle pid.
|
|
707
|
+
*/
|
|
708
|
+
function classifyTeamGcWorkerPids(pids: number[], probe: GcPidProbe): TeamGcPidClassification {
|
|
709
|
+
if (pids.length === 0) return { removable: false, pidStatus: "none" };
|
|
710
|
+
const statuses = pids.map(pid => ({ pid, status: gcProbeStatus(probe, pid) }));
|
|
711
|
+
const kept = statuses.find(entry => entry.status !== "dead");
|
|
712
|
+
if (kept) return { removable: false, pidStatus: kept.status, pid: kept.pid };
|
|
713
|
+
return { removable: true, pidStatus: "dead", pid: statuses[0]?.pid };
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
function gcProbeStatus(probe: GcPidProbe, pid: number): "dead" | "alive" | "eperm" | "unknown" {
|
|
717
|
+
const result = probe(pid);
|
|
718
|
+
if (result.status === "dead") return "dead";
|
|
719
|
+
return result.reason ?? "unknown";
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function teamGcRecordDetail(heartbeat: WorkerHeartbeatFile | null, lifecycle: GjcTeamWorkerLifecycle | null): string {
|
|
723
|
+
return [
|
|
724
|
+
`heartbeat=${heartbeat ? "present" : "missing"}`,
|
|
725
|
+
...(heartbeat ? [`heartbeat_alive=${heartbeat.alive}`, `last_turn_at=${heartbeat.last_turn_at}`] : []),
|
|
726
|
+
`lifecycle=${lifecycle?.lifecycle_state ?? "missing"}`,
|
|
727
|
+
...(lifecycle?.pane_id ? [`pane_id=${lifecycle.pane_id}`] : []),
|
|
728
|
+
...(lifecycle?.stop_reason ? [`stop_reason=${lifecycle.stop_reason}`] : []),
|
|
729
|
+
].join(" ");
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/** @internal */
|
|
733
|
+
export async function listTeamWorkerGcRecords(teamRoot: string, probe: GcPidProbe): Promise<GcRecord[]> {
|
|
734
|
+
const teamEntries = await fs.readdir(teamRoot, { withFileTypes: true });
|
|
735
|
+
const records: GcRecord[] = [];
|
|
736
|
+
for (const teamEntry of teamEntries) {
|
|
737
|
+
if (!teamEntry.isDirectory()) continue;
|
|
738
|
+
const teamName = teamEntry.name;
|
|
739
|
+
const teamDirPath = path.join(teamRoot, teamName);
|
|
740
|
+
let workerEntries: import("node:fs").Dirent[];
|
|
741
|
+
try {
|
|
742
|
+
workerEntries = await fs.readdir(path.join(teamDirPath, "workers"), { withFileTypes: true });
|
|
743
|
+
} catch (error) {
|
|
744
|
+
if (isEnoent(error)) continue;
|
|
745
|
+
throw error;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
for (const workerEntry of workerEntries) {
|
|
749
|
+
if (!workerEntry.isDirectory()) continue;
|
|
750
|
+
const workerId = workerEntry.name;
|
|
751
|
+
const dir = path.join(teamDirPath, "workers", workerId);
|
|
752
|
+
let heartbeat: WorkerHeartbeatFile | null = null;
|
|
753
|
+
let lifecycle: GjcTeamWorkerLifecycle | null = null;
|
|
754
|
+
try {
|
|
755
|
+
heartbeat = await readJsonFile<WorkerHeartbeatFile>(path.join(dir, "heartbeat.json"));
|
|
756
|
+
lifecycle = await readJsonFile<GjcTeamWorkerLifecycle>(path.join(dir, "lifecycle.json"));
|
|
757
|
+
} catch (error) {
|
|
758
|
+
records.push({
|
|
759
|
+
store: "team_workers",
|
|
760
|
+
id: `${teamName}/${workerId}`,
|
|
761
|
+
root: teamRoot,
|
|
762
|
+
path: dir,
|
|
763
|
+
pid_status: "none",
|
|
764
|
+
status: "malformed",
|
|
765
|
+
stale: false,
|
|
766
|
+
removable: false,
|
|
767
|
+
action: "none",
|
|
768
|
+
reason: "worker_state_malformed_kept",
|
|
769
|
+
error: error instanceof Error ? error.message : String(error),
|
|
770
|
+
});
|
|
771
|
+
continue;
|
|
772
|
+
}
|
|
773
|
+
const pids = collectTeamGcWorkerPids(heartbeat, lifecycle);
|
|
774
|
+
const { removable, pidStatus, pid } = classifyTeamGcWorkerPids(pids, probe);
|
|
775
|
+
const terminalLifecycle = lifecycle?.lifecycle_state === "failed" || lifecycle?.lifecycle_state === "stopped";
|
|
776
|
+
const status = removable
|
|
777
|
+
? "dead"
|
|
778
|
+
: pidStatus === "none" && terminalLifecycle
|
|
779
|
+
? "terminal_lifecycle"
|
|
780
|
+
: pidStatus === "none"
|
|
781
|
+
? "no_pid"
|
|
782
|
+
: pidStatus;
|
|
783
|
+
records.push({
|
|
784
|
+
store: "team_workers",
|
|
785
|
+
id: `${teamName}/${workerId}`,
|
|
786
|
+
root: teamRoot,
|
|
787
|
+
path: dir,
|
|
788
|
+
pid,
|
|
789
|
+
pid_status: pidStatus,
|
|
790
|
+
status,
|
|
791
|
+
stale: removable,
|
|
792
|
+
removable,
|
|
793
|
+
action: "none",
|
|
794
|
+
reason: removable
|
|
795
|
+
? "worker_all_pids_dead"
|
|
796
|
+
: pidStatus === "none" && terminalLifecycle
|
|
797
|
+
? "terminal_lifecycle_without_pid_kept"
|
|
798
|
+
: pidStatus === "none"
|
|
799
|
+
? "worker_pid_missing_kept"
|
|
800
|
+
: `worker_pid_${pidStatus}_kept`,
|
|
801
|
+
detail: teamGcRecordDetail(heartbeat, lifecycle),
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
return records;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
/** @internal */
|
|
809
|
+
export async function pruneTeamWorkerGcRecord(record: GcRecord, probe: GcPidProbe): Promise<boolean> {
|
|
810
|
+
if (!record.path || !record.id.includes("/")) return false;
|
|
811
|
+
const [teamName, workerId] = record.id.split("/", 2);
|
|
812
|
+
if (!teamName || !workerId) return false;
|
|
813
|
+
const teamDirPath = path.dirname(path.dirname(record.path));
|
|
814
|
+
const heartbeat = await readJsonFile<WorkerHeartbeatFile>(path.join(record.path, "heartbeat.json"));
|
|
815
|
+
const lifecycle = await readJsonFile<GjcTeamWorkerLifecycle>(path.join(record.path, "lifecycle.json"));
|
|
816
|
+
const pids = collectTeamGcWorkerPids(heartbeat, lifecycle);
|
|
817
|
+
if (!classifyTeamGcWorkerPids(pids, probe).removable) return false;
|
|
818
|
+
|
|
819
|
+
const claimDir = path.join(teamDirPath, "claims");
|
|
820
|
+
try {
|
|
821
|
+
for (const entry of await fs.readdir(claimDir, { withFileTypes: true })) {
|
|
822
|
+
if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
|
|
823
|
+
const claimPath = path.join(claimDir, entry.name);
|
|
824
|
+
const claim = readClaimRecord(await readJsonFile<unknown>(claimPath));
|
|
825
|
+
if (claim?.owner !== workerId) continue;
|
|
826
|
+
await removeFileAudited(claimPath, stateWriterOptions(claimPath, "prune", "gc-team-worker"));
|
|
827
|
+
}
|
|
828
|
+
} catch (error) {
|
|
829
|
+
if (!isEnoent(error)) throw error;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
for (const task of await readTasks(teamDirPath)) {
|
|
833
|
+
if (task.claim?.owner !== workerId && task.assignee !== workerId) continue;
|
|
834
|
+
if (task.status === "completed" || task.status === "failed") continue;
|
|
835
|
+
await writeTask(teamDirPath, {
|
|
836
|
+
...task,
|
|
837
|
+
status: "pending",
|
|
838
|
+
assignee: undefined,
|
|
839
|
+
claim: undefined,
|
|
840
|
+
version: task.version + 1,
|
|
841
|
+
updated_at: now(),
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
// Remove the stale worker record dir itself so a removable record always
|
|
846
|
+
// results in an observable removal, even when it owns no claims/tasks.
|
|
847
|
+
await fs.rm(record.path, { recursive: true, force: true });
|
|
848
|
+
return true;
|
|
849
|
+
}
|
|
675
850
|
function stateCategoryForJsonPath(filePath: string): "state" | "ledger" {
|
|
676
851
|
return filePath.endsWith(".jsonl") || filePath.includes(`${path.sep}telemetry${path.sep}`) ? "ledger" : "state";
|
|
677
852
|
}
|
|
@@ -1622,16 +1797,13 @@ async function ensureWorkerWorktree(
|
|
|
1622
1797
|
};
|
|
1623
1798
|
}
|
|
1624
1799
|
|
|
1625
|
-
export function resolveGjcTmuxCommand(env: NodeJS.ProcessEnv = process.env): string {
|
|
1626
|
-
return env.GJC_TEAM_TMUX_COMMAND?.trim() || "tmux";
|
|
1627
|
-
}
|
|
1628
1800
|
function buildTeamTmuxLeaderRequirementMessage(detail?: string): string {
|
|
1629
1801
|
const suffix = detail?.trim() ? `:${detail.trim()}` : "";
|
|
1630
1802
|
return `gjc_team_requires_tmux_leader: run \`gjc --tmux\` first, then run \`gjc team ...\` inside that tmux-backed leader session, or use \`gjc team --dry-run\` for state-only smoke tests${suffix}`;
|
|
1631
1803
|
}
|
|
1632
1804
|
function readGjcTmuxProfileValue(tmuxCommand: string, sessionName: string): string {
|
|
1633
1805
|
const result = Bun.spawnSync(
|
|
1634
|
-
[tmuxCommand, "show-options", "-qv", "-t",
|
|
1806
|
+
[tmuxCommand, "show-options", "-qv", "-t", buildGjcTmuxExactOptionTarget(sessionName), GJC_TMUX_PROFILE_OPTION],
|
|
1635
1807
|
{
|
|
1636
1808
|
stdout: "pipe",
|
|
1637
1809
|
stderr: "pipe",
|
|
@@ -1641,6 +1813,24 @@ function readGjcTmuxProfileValue(tmuxCommand: string, sessionName: string): stri
|
|
|
1641
1813
|
return result.stdout.toString().trim();
|
|
1642
1814
|
}
|
|
1643
1815
|
|
|
1816
|
+
function retagGjcLaunchedTmuxSession(tmuxCommand: string, sessionName: string): boolean {
|
|
1817
|
+
const result = Bun.spawnSync(
|
|
1818
|
+
[
|
|
1819
|
+
tmuxCommand,
|
|
1820
|
+
"set-option",
|
|
1821
|
+
"-t",
|
|
1822
|
+
buildGjcTmuxExactOptionTarget(sessionName),
|
|
1823
|
+
GJC_TMUX_PROFILE_OPTION,
|
|
1824
|
+
GJC_TMUX_PROFILE_VALUE,
|
|
1825
|
+
],
|
|
1826
|
+
{
|
|
1827
|
+
stdout: "pipe",
|
|
1828
|
+
stderr: "pipe",
|
|
1829
|
+
},
|
|
1830
|
+
);
|
|
1831
|
+
return result.exitCode === 0;
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1644
1834
|
function readCurrentTmuxLeaderContext(tmuxCommand: string, env: NodeJS.ProcessEnv): GjcTmuxLeaderContext {
|
|
1645
1835
|
const paneTarget = env.TMUX_PANE?.trim();
|
|
1646
1836
|
const args = paneTarget
|
|
@@ -1652,8 +1842,21 @@ function readCurrentTmuxLeaderContext(tmuxCommand: string, env: NodeJS.ProcessEn
|
|
|
1652
1842
|
const [sessionName = "", windowIndex = ""] = sessionAndWindow.split(":");
|
|
1653
1843
|
if (!sessionName || !windowIndex || !leaderPaneId.startsWith("%"))
|
|
1654
1844
|
throw new Error(buildTeamTmuxLeaderRequirementMessage(`invalid_tmux_context:${result.stdout.toString().trim()}`));
|
|
1655
|
-
if (readGjcTmuxProfileValue(tmuxCommand, sessionName) !== GJC_TMUX_PROFILE_VALUE)
|
|
1656
|
-
|
|
1845
|
+
if (readGjcTmuxProfileValue(tmuxCommand, sessionName) !== GJC_TMUX_PROFILE_VALUE) {
|
|
1846
|
+
// Self-heal: a pane launched through `gjc --tmux` exports
|
|
1847
|
+
// GJC_TMUX_LAUNCHED=1, but the session can lose (or never receive) the
|
|
1848
|
+
// @gjc-profile user-option tag when startup attach fails mid-way or the
|
|
1849
|
+
// registry write races. That stranded-but-genuinely-GJC leader pane
|
|
1850
|
+
// previously hard-failed as unmanaged_tmux_session; re-tag it instead.
|
|
1851
|
+
const launchedByGjc = env[GJC_TMUX_LAUNCHED_ENV] === "1";
|
|
1852
|
+
const retagged = launchedByGjc && retagGjcLaunchedTmuxSession(tmuxCommand, sessionName);
|
|
1853
|
+
if (!retagged || readGjcTmuxProfileValue(tmuxCommand, sessionName) !== GJC_TMUX_PROFILE_VALUE)
|
|
1854
|
+
throw new Error(
|
|
1855
|
+
buildTeamTmuxLeaderRequirementMessage(
|
|
1856
|
+
`unmanaged_tmux_session:${sessionName} — ${buildGjcTmuxUntaggedSessionHint(tmuxCommand)}`,
|
|
1857
|
+
),
|
|
1858
|
+
);
|
|
1859
|
+
}
|
|
1657
1860
|
return { sessionName, windowIndex, leaderPaneId, target: `${sessionName}:${windowIndex}` };
|
|
1658
1861
|
}
|
|
1659
1862
|
export function resolveGjcWorkerCommand(cwd = process.cwd(), env: NodeJS.ProcessEnv = process.env): string {
|
|
@@ -32,6 +32,35 @@ export function resolveGjcTmuxCommand(env: NodeJS.ProcessEnv = process.env): str
|
|
|
32
32
|
return env[GJC_TMUX_COMMAND_ENV]?.trim() || env.GJC_TEAM_TMUX_COMMAND?.trim() || "tmux";
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Build the exact-session target for tmux *option* commands
|
|
37
|
+
* (`show-options` / `set-option`) and `display-message -t`.
|
|
38
|
+
*
|
|
39
|
+
* Session-scoped commands such as `kill-session` / `attach-session` resolve a
|
|
40
|
+
* bare exact target (`=NAME`), but tmux 3.6a refuses to resolve a bare `=NAME`
|
|
41
|
+
* for option/display commands. Appending the empty window separator (`=NAME:`)
|
|
42
|
+
* keeps the exact-session match while giving tmux the window-qualified target
|
|
43
|
+
* those commands require. See gajae-code#580.
|
|
44
|
+
*/
|
|
45
|
+
export function buildGjcTmuxExactOptionTarget(sessionName: string): string {
|
|
46
|
+
return `=${sessionName}:`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const GJC_TMUX_UNTAGGED_REASON = "gjc_tmux_session_untagged";
|
|
50
|
+
|
|
51
|
+
export function buildGjcTmuxUntaggedSessionHint(tmuxCommand: string): string {
|
|
52
|
+
return (
|
|
53
|
+
`the active multiplexer "${tmuxCommand}" lists this session but did not return GJC's ${GJC_TMUX_PROFILE_OPTION} ownership tag; ` +
|
|
54
|
+
"GJC-managed sessions and `gjc team` require a tmux provider that round-trips tmux user options. " +
|
|
55
|
+
"Alternative multiplexers such as psmux on Windows do not persist user options yet, so the Windows-native psmux path is not fully supported; " +
|
|
56
|
+
"use real tmux for GJC-managed session and team flows."
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function buildGjcTmuxUntaggedSessionError(sessionName: string, tmuxCommand: string): string {
|
|
61
|
+
return `${GJC_TMUX_UNTAGGED_REASON}:${sessionName} — ${buildGjcTmuxUntaggedSessionHint(tmuxCommand)}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
35
64
|
export function sanitizeTmuxToken(value: string): string {
|
|
36
65
|
return (
|
|
37
66
|
value
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GC adapter for gjc-tagged tmux sessions. Stale iff `@gjc-project` path is gone
|
|
3
|
+
* OR `@gjc-branch` has no live git worktree. Removal is a spec-authorized
|
|
4
|
+
* destructive `kill-session`, gated by exact-target re-read + revalidation.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as fs from "node:fs";
|
|
8
|
+
|
|
9
|
+
import { worktree } from "../utils/git";
|
|
10
|
+
import type { GcCollectResult, GcContext, GcPruneOutcome, GcRecord, GcStoreAdapter } from "./gc-runtime";
|
|
11
|
+
import { GJC_TMUX_PROFILE_VALUE } from "./tmux-common";
|
|
12
|
+
import {
|
|
13
|
+
type GjcTmuxSessionStatus,
|
|
14
|
+
listTmuxSessionsForGc,
|
|
15
|
+
readTmuxSessionTagsForGc,
|
|
16
|
+
removeGjcTmuxSession,
|
|
17
|
+
} from "./tmux-sessions";
|
|
18
|
+
|
|
19
|
+
const STORE = "tmux_sessions" as const;
|
|
20
|
+
const TOCTOU_SKIP = "tmux_revalidation_failed_or_became_live";
|
|
21
|
+
|
|
22
|
+
function pathExists(path: string): boolean {
|
|
23
|
+
try {
|
|
24
|
+
return fs.existsSync(path);
|
|
25
|
+
} catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function detail(project?: string, branch?: string): string | undefined {
|
|
31
|
+
const parts = [];
|
|
32
|
+
if (project) parts.push(`project=${project}`);
|
|
33
|
+
if (branch) parts.push(`branch=${branch}`);
|
|
34
|
+
return parts.length > 0 ? parts.join(" ") : undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function unclassifiedRecord(id: string, reason: string, project?: string, branch?: string): GcRecord {
|
|
38
|
+
return {
|
|
39
|
+
store: STORE,
|
|
40
|
+
id,
|
|
41
|
+
path: project,
|
|
42
|
+
root: project,
|
|
43
|
+
pid_status: "none",
|
|
44
|
+
status: "unclassified",
|
|
45
|
+
stale: false,
|
|
46
|
+
removable: false,
|
|
47
|
+
action: "none",
|
|
48
|
+
reason,
|
|
49
|
+
detail: detail(project, branch),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function branchMatches(candidate: string | undefined, branch: string): boolean {
|
|
54
|
+
if (!candidate) return false;
|
|
55
|
+
const branchNames = new Set([
|
|
56
|
+
branch,
|
|
57
|
+
branch.startsWith("refs/heads/") ? branch.slice("refs/heads/".length) : `refs/heads/${branch}`,
|
|
58
|
+
]);
|
|
59
|
+
return branchNames.has(candidate);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function hasLiveWorktreeForBranch(project: string, branch: string): Promise<boolean> {
|
|
63
|
+
const entries = await worktree.list(project);
|
|
64
|
+
return entries.some(entry => branchMatches(entry.branch, branch));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function classifyTaggedSession(session: GjcTmuxSessionStatus): Promise<GcRecord> {
|
|
68
|
+
const { name, project, branch } = session;
|
|
69
|
+
if (!project || !branch) return unclassifiedRecord(name, "missing_project_or_branch_tag", project, branch);
|
|
70
|
+
if (!pathExists(project)) {
|
|
71
|
+
return {
|
|
72
|
+
store: STORE,
|
|
73
|
+
id: name,
|
|
74
|
+
path: project,
|
|
75
|
+
root: project,
|
|
76
|
+
pid_status: "none",
|
|
77
|
+
status: "stale",
|
|
78
|
+
stale: true,
|
|
79
|
+
removable: true,
|
|
80
|
+
action: "none",
|
|
81
|
+
reason: "project_missing",
|
|
82
|
+
detail: detail(project, branch),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
if (!(await hasLiveWorktreeForBranch(project, branch))) {
|
|
86
|
+
return {
|
|
87
|
+
store: STORE,
|
|
88
|
+
id: name,
|
|
89
|
+
path: project,
|
|
90
|
+
root: project,
|
|
91
|
+
pid_status: "none",
|
|
92
|
+
status: "stale",
|
|
93
|
+
stale: true,
|
|
94
|
+
removable: true,
|
|
95
|
+
action: "none",
|
|
96
|
+
reason: "branch_no_worktree",
|
|
97
|
+
detail: detail(project, branch),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
store: STORE,
|
|
102
|
+
id: name,
|
|
103
|
+
path: project,
|
|
104
|
+
root: project,
|
|
105
|
+
pid_status: "none",
|
|
106
|
+
status: "live",
|
|
107
|
+
stale: false,
|
|
108
|
+
removable: false,
|
|
109
|
+
action: "none",
|
|
110
|
+
reason: "project_and_branch_worktree_present",
|
|
111
|
+
detail: detail(project, branch),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function revalidateRemovable(name: string, env: NodeJS.ProcessEnv): Promise<boolean> {
|
|
116
|
+
const tags = readTmuxSessionTagsForGc(name, env);
|
|
117
|
+
if (tags.profile !== GJC_TMUX_PROFILE_VALUE || !tags.project || !tags.branch) return false;
|
|
118
|
+
if (!pathExists(tags.project)) return true;
|
|
119
|
+
return !(await hasLiveWorktreeForBranch(tags.project, tags.branch));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export const tmuxSessionsGcAdapter: GcStoreAdapter = {
|
|
123
|
+
store: STORE,
|
|
124
|
+
async collect(ctx: GcContext): Promise<GcCollectResult> {
|
|
125
|
+
const records: GcRecord[] = [];
|
|
126
|
+
const errors: GcCollectResult["errors"] = [];
|
|
127
|
+
let sessions: ReturnType<typeof listTmuxSessionsForGc>;
|
|
128
|
+
try {
|
|
129
|
+
sessions = listTmuxSessionsForGc(ctx.env);
|
|
130
|
+
} catch (error) {
|
|
131
|
+
return {
|
|
132
|
+
records,
|
|
133
|
+
errors: [
|
|
134
|
+
{
|
|
135
|
+
store: STORE,
|
|
136
|
+
scope: "list_sessions",
|
|
137
|
+
message: error instanceof Error ? error.message : String(error),
|
|
138
|
+
},
|
|
139
|
+
],
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
for (const session of sessions.tagged) {
|
|
144
|
+
try {
|
|
145
|
+
records.push(await classifyTaggedSession(session));
|
|
146
|
+
} catch (error) {
|
|
147
|
+
errors.push({
|
|
148
|
+
store: STORE,
|
|
149
|
+
scope: session.name,
|
|
150
|
+
message: error instanceof Error ? error.message : String(error),
|
|
151
|
+
});
|
|
152
|
+
records.push(unclassifiedRecord(session.name, "worktree_list_failed", session.project, session.branch));
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
for (const name of sessions.untagged) {
|
|
157
|
+
records.push(unclassifiedRecord(name, "untagged_tmux_session"));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return { records, errors };
|
|
161
|
+
},
|
|
162
|
+
async prune(record: GcRecord, ctx: GcContext): Promise<GcPruneOutcome> {
|
|
163
|
+
if (record.store !== STORE || record.status !== "stale" || !record.removable) {
|
|
164
|
+
return { removed: false, skipped: "not_removable_tmux_session" };
|
|
165
|
+
}
|
|
166
|
+
try {
|
|
167
|
+
if (!(await revalidateRemovable(record.id, ctx.env))) {
|
|
168
|
+
return { removed: false, skipped: TOCTOU_SKIP };
|
|
169
|
+
}
|
|
170
|
+
removeGjcTmuxSession(record.id, ctx.env);
|
|
171
|
+
return { removed: true };
|
|
172
|
+
} catch (error) {
|
|
173
|
+
return { removed: false, error: error instanceof Error ? error.message : String(error) };
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
};
|