@gajae-code/coding-agent 0.12.5 → 0.12.7
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 +34 -0
- package/README.md +4 -1
- package/dist/types/async/job-manager.d.ts +36 -1
- package/dist/types/extensibility/extensions/runner.d.ts +9 -7
- package/dist/types/gjc-runtime/launch-tmux.d.ts +22 -3
- package/dist/types/gjc-runtime/psmux-detect.d.ts +3 -0
- package/dist/types/gjc-runtime/team-launch.d.ts +3 -1
- package/dist/types/gjc-runtime/team-runtime.d.ts +14 -2
- package/dist/types/gjc-runtime/team-store.d.ts +0 -4
- package/dist/types/gjc-runtime/team-workers.d.ts +5 -0
- package/dist/types/gjc-runtime/tmux-common.d.ts +1 -0
- package/dist/types/gjc-runtime/tmux-provider-context.d.ts +57 -0
- package/dist/types/gjc-runtime/tmux-sessions.d.ts +8 -1
- package/dist/types/gjc-runtime/ultragoal-change-set.d.ts +3 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +21 -4
- package/dist/types/modes/components/gjc-bundle-settings.d.ts +1 -0
- package/dist/types/modes/components/plugin-settings.d.ts +2 -0
- package/dist/types/modes/components/settings-selector.d.ts +2 -0
- package/dist/types/sdk/broker/broker.d.ts +18 -0
- package/dist/types/sdk/broker/lifecycle-ledger.d.ts +13 -0
- package/dist/types/sdk/broker/lifecycle.d.ts +1 -0
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +2 -3
- package/dist/types/sdk/bus/index.d.ts +4 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +2 -2
- package/dist/types/sdk/bus/telegram-daemon.d.ts +25 -3
- package/dist/types/sdk/bus/topic-registry.d.ts +63 -3
- package/dist/types/session/agent-session.d.ts +5 -4
- package/dist/types/session/attempt-record-store.d.ts +19 -0
- package/dist/types/session/session-manager.d.ts +1 -0
- package/dist/types/session/session-storage.d.ts +8 -0
- package/dist/types/task/receipt.d.ts +1 -0
- package/dist/types/task/types.d.ts +43 -0
- package/dist/types/tools/subagent.d.ts +15 -0
- package/dist/types/tools/todo-write.d.ts +3 -0
- package/package.json +8 -8
- package/scripts/capture-sticky-viewport-showcase.ts +131 -5
- package/scripts/verify-gjc-sdk-canonicalization.ts +128 -183
- package/scripts/verify-sticky-viewport-showcase.ts +620 -93
- package/src/async/job-manager.ts +430 -46
- package/src/config/model-profiles.ts +8 -0
- package/src/config/settings-schema.ts +17 -3
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +2 -0
- package/src/extensibility/extensions/runner.ts +80 -6
- package/src/extensibility/extensions/wrapper.ts +22 -15
- package/src/extensibility/runtime-skill-discovery.ts +4 -6
- package/src/gjc-runtime/launch-tmux.ts +309 -293
- package/src/gjc-runtime/managed-owner-supervisor.ts +6 -2
- package/src/gjc-runtime/psmux-detect.ts +52 -6
- package/src/gjc-runtime/team-launch.ts +50 -2
- package/src/gjc-runtime/team-runtime.ts +716 -251
- package/src/gjc-runtime/team-store.ts +25 -7
- package/src/gjc-runtime/team-workers.ts +161 -1
- package/src/gjc-runtime/tmux-common.ts +16 -4
- package/src/gjc-runtime/tmux-gc.ts +37 -5
- package/src/gjc-runtime/tmux-owner-isolation.ts +2 -0
- package/src/gjc-runtime/tmux-provider-context.ts +451 -0
- package/src/gjc-runtime/tmux-sessions.ts +409 -156
- package/src/gjc-runtime/ultragoal-change-set.ts +90 -44
- package/src/gjc-runtime/ultragoal-receipt-freshness.ts +7 -0
- package/src/gjc-runtime/ultragoal-runtime.ts +360 -156
- package/src/internal-urls/docs-index.generated.ts +4 -4
- package/src/internal-urls/local-protocol.ts +39 -18
- package/src/modes/acp/acp-agent.ts +27 -13
- package/src/modes/components/gjc-bundle-settings.ts +2 -0
- package/src/modes/components/plugin-settings.ts +5 -0
- package/src/modes/components/settings-selector.ts +4 -0
- package/src/modes/controllers/selector-controller.ts +1 -0
- package/src/prompts/tools/monitor.md +4 -4
- package/src/sdk/broker/broker.ts +93 -10
- package/src/sdk/broker/lifecycle-ledger.ts +227 -11
- package/src/sdk/broker/lifecycle.ts +691 -92
- package/src/sdk/bus/chat-daemon-control.ts +30 -6
- package/src/sdk/bus/index.ts +23 -6
- package/src/sdk/bus/telegram-daemon-contract.ts +2 -2
- package/src/sdk/bus/telegram-daemon.ts +283 -44
- package/src/sdk/bus/topic-registry.ts +133 -10
- package/src/sdk/session.ts +7 -6
- package/src/session/agent-session.ts +752 -339
- package/src/session/attempt-record-store.ts +106 -0
- package/src/session/internal/managed-session-scope.ts +252 -67
- package/src/session/internal/managed-session-storage.ts +1 -0
- package/src/session/session-manager.ts +7 -0
- package/src/session/session-storage.ts +146 -24
- package/src/task/index.ts +230 -19
- package/src/task/provider-retry-status.ts +4 -1
- package/src/task/receipt.ts +2 -0
- package/src/task/types.ts +10 -0
- package/src/tools/monitor.ts +7 -1
- package/src/tools/output-meta.ts +10 -2
- package/src/tools/subagent.ts +97 -75
- package/src/tools/todo-write.ts +47 -0
|
@@ -6,9 +6,13 @@ import type { WorkflowHudSummary } from "../skill-state/active-state";
|
|
|
6
6
|
import { buildTeamHudSummary as buildWorkflowTeamHudSummary } from "../skill-state/workflow-hud";
|
|
7
7
|
import { WORKFLOW_STATE_VERSION } from "../skill-state/workflow-state-contract";
|
|
8
8
|
import type { GcPidProbe, GcRecord } from "./gc-runtime";
|
|
9
|
-
import { applyGjcTmuxProfile } from "./launch-tmux";
|
|
10
9
|
import { modeStatePath, sessionIdFromDirName, sessionReportsDir, teamStateRoot } from "./session-layout";
|
|
11
10
|
import { resolveGjcSessionForWrite, writeSessionActivityMarker } from "./session-resolution";
|
|
11
|
+
import {
|
|
12
|
+
GJC_COORDINATOR_SESSION_ID_ENV,
|
|
13
|
+
GJC_TMUX_OWNER_GENERATION_ENV,
|
|
14
|
+
GJC_TMUX_OWNER_STATE_DIR_ENV,
|
|
15
|
+
} from "./session-state-sidecar";
|
|
12
16
|
import {
|
|
13
17
|
AlreadyExistsError,
|
|
14
18
|
appendJsonl as appendJsonlAudited,
|
|
@@ -67,10 +71,13 @@ import {
|
|
|
67
71
|
workerMemoryGuardLedgerPath,
|
|
68
72
|
} from "./team-worker-memory-guard";
|
|
69
73
|
import {
|
|
74
|
+
buildGjcContinuationPrompt,
|
|
75
|
+
GJC_TEAM_CONTINUATION_ACK_POLL_MS,
|
|
70
76
|
GJC_TEAM_CONTINUATION_PROMPT,
|
|
71
77
|
type GjcTeamWorkerOrchestrationRuntime,
|
|
72
78
|
type GjcTeamWorkerRuntime,
|
|
73
79
|
gjcContinuationReservationDigest,
|
|
80
|
+
isValidGjcContinuationAck,
|
|
74
81
|
isValidGjcContinuationOutcome,
|
|
75
82
|
isValidGjcContinuationReservation,
|
|
76
83
|
readGjcShutdownAuthority,
|
|
@@ -85,10 +92,12 @@ import {
|
|
|
85
92
|
writeWorkerLifecycleForConfig as writeLifecycleForConfig,
|
|
86
93
|
writeWorkerLifecycleRecord as writeLifecycleRecord,
|
|
87
94
|
writeGjcShutdownRequest as writeShutdownRequest,
|
|
95
|
+
writeGjcWorkerContinuationAck as writeWorkerContinuationAck,
|
|
88
96
|
writeGjcWorkerStartupAck as writeWorkerStartupAck,
|
|
89
97
|
} from "./team-workers";
|
|
90
98
|
import {
|
|
91
99
|
buildGjcTmuxExactOptionTarget,
|
|
100
|
+
buildGjcTmuxProfileCommands,
|
|
92
101
|
buildGjcTmuxUntaggedSessionHint,
|
|
93
102
|
GJC_TMUX_ACTIVE_SESSION_ENV,
|
|
94
103
|
GJC_TMUX_PROFILE_OPTION,
|
|
@@ -96,6 +105,15 @@ import {
|
|
|
96
105
|
resolveGjcTmuxBinary,
|
|
97
106
|
resolveGjcTmuxCommand,
|
|
98
107
|
} from "./tmux-common";
|
|
108
|
+
import {
|
|
109
|
+
assertGjcTmuxMutationAuthoritySync,
|
|
110
|
+
bindGjcTmuxProviderAuthority,
|
|
111
|
+
buildTmuxProviderCommand,
|
|
112
|
+
hasGjcTmuxProviderAuthoritySync,
|
|
113
|
+
type ProviderAuthority,
|
|
114
|
+
readGjcTmuxProviderAuthoritySync,
|
|
115
|
+
resolveGjcTmuxProviderContext,
|
|
116
|
+
} from "./tmux-provider-context";
|
|
99
117
|
|
|
100
118
|
export type {
|
|
101
119
|
GjcTeamApiClaimResult,
|
|
@@ -163,6 +181,12 @@ export type GjcTeamWorktreeMode =
|
|
|
163
181
|
| { enabled: true; detached: false; name: string };
|
|
164
182
|
|
|
165
183
|
export interface GjcTeamConfig {
|
|
184
|
+
/**
|
|
185
|
+
* Launch-time platform. Persisted so the monitor path resolves the same
|
|
186
|
+
* provider context the launch did instead of re-reading `process.platform`,
|
|
187
|
+
* which makes Windows-authority branches unreachable off Windows.
|
|
188
|
+
*/
|
|
189
|
+
platform?: NodeJS.Platform;
|
|
166
190
|
team_name: string;
|
|
167
191
|
display_name: string;
|
|
168
192
|
requested_name: string;
|
|
@@ -178,6 +202,9 @@ export interface GjcTeamConfig {
|
|
|
178
202
|
tmux_session: string;
|
|
179
203
|
tmux_session_name: string;
|
|
180
204
|
tmux_target: string;
|
|
205
|
+
tmux_provider_generation?: string;
|
|
206
|
+
tmux_provider_state_dir?: string;
|
|
207
|
+
tmux_provider_session_id?: string;
|
|
181
208
|
workspace_mode: "direct" | "worktree";
|
|
182
209
|
dry_run: boolean;
|
|
183
210
|
leader: GjcTeamLeader;
|
|
@@ -519,6 +546,7 @@ export const GJC_TEAM_API_OPERATIONS = [
|
|
|
519
546
|
"notification-replay",
|
|
520
547
|
"notification-mark-pane-attempt",
|
|
521
548
|
"worker-startup-ack",
|
|
549
|
+
"worker-continuation-ack",
|
|
522
550
|
"create-task",
|
|
523
551
|
"read-task",
|
|
524
552
|
"list-tasks",
|
|
@@ -595,9 +623,12 @@ function now(): string {
|
|
|
595
623
|
|
|
596
624
|
export interface GjcTeamRuntimeTestSeams {
|
|
597
625
|
nowMs?: () => number;
|
|
598
|
-
continuationTmuxDispatch?: (
|
|
599
|
-
|
|
626
|
+
continuationTmuxDispatch?: (
|
|
627
|
+
command: string,
|
|
628
|
+
args: readonly string[],
|
|
629
|
+
) => { exitCode?: number } | Promise<{ exitCode?: number }>;
|
|
600
630
|
continuationBeforeDispatch?: () => Promise<void>;
|
|
631
|
+
continuationAckPoll?: () => Promise<void>;
|
|
601
632
|
}
|
|
602
633
|
|
|
603
634
|
let gjcTeamRuntimeTestSeams: GjcTeamRuntimeTestSeams | undefined;
|
|
@@ -1001,8 +1032,15 @@ export async function listTeamWorkerGcRecords(teamRoot: string, probe: GcPidProb
|
|
|
1001
1032
|
export async function pruneTeamWorkerGcRecord(record: GcRecord, probe: GcPidProbe): Promise<boolean> {
|
|
1002
1033
|
if (!record.path || !record.id.includes("/")) return false;
|
|
1003
1034
|
const teamDirPath = path.dirname(path.dirname(record.path));
|
|
1004
|
-
|
|
1005
|
-
|
|
1035
|
+
// Prune deletes claim records and rewrites claimed tasks, so it is an
|
|
1036
|
+
// authority-changing operation and must take the same team mutation fence the
|
|
1037
|
+
// rest of the public surface takes. Without it, prune can strip a claim inside
|
|
1038
|
+
// the continuation dispatch window and silently suppress a stalled-worker
|
|
1039
|
+
// continuation.
|
|
1040
|
+
return withGjcTeamMutationFence(teamDirPath, () =>
|
|
1041
|
+
withGjcTeamTaskMutation(taskStore(teamDirPath), capability =>
|
|
1042
|
+
pruneTeamWorkerGcRecordUnlocked(record, probe, capability),
|
|
1043
|
+
),
|
|
1006
1044
|
);
|
|
1007
1045
|
}
|
|
1008
1046
|
|
|
@@ -1285,6 +1323,7 @@ function manifestRecordFromConfig(config: GjcTeamConfig): Record<string, unknown
|
|
|
1285
1323
|
worker_command: config.worker_command,
|
|
1286
1324
|
worker_cli_plan: config.worker_cli_plan,
|
|
1287
1325
|
tmux_command: config.tmux_command,
|
|
1326
|
+
tmux_provider_generation: config.tmux_provider_generation,
|
|
1288
1327
|
leader: config.leader,
|
|
1289
1328
|
workers: config.workers,
|
|
1290
1329
|
workspace_mode: config.workspace_mode,
|
|
@@ -1540,40 +1579,37 @@ async function relaunchWorkerPaneForMemoryGuard(input: {
|
|
|
1540
1579
|
input.env,
|
|
1541
1580
|
);
|
|
1542
1581
|
const workerCwd = input.worker.worktree_path ?? input.config.leader.cwd;
|
|
1543
|
-
const useSendKeysFallback = shouldDispatchWorkerWithSendKeys(
|
|
1582
|
+
const useSendKeysFallback = shouldDispatchWorkerWithSendKeys(
|
|
1583
|
+
input.config.tmux_command,
|
|
1584
|
+
input.config.tmux_provider_generation,
|
|
1585
|
+
);
|
|
1544
1586
|
const splitTarget =
|
|
1545
1587
|
input.worker.pane_id && paneBelongsToTeamTarget(input.config, input.worker.pane_id)
|
|
1546
1588
|
? input.worker.pane_id
|
|
1547
1589
|
: input.config.tmux_target;
|
|
1548
|
-
const split =
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
"-d",
|
|
1556
|
-
"-P",
|
|
1557
|
-
"-F",
|
|
1558
|
-
"#{pane_id}",
|
|
1559
|
-
"-c",
|
|
1560
|
-
workerCwd,
|
|
1561
|
-
...(useSendKeysFallback ? [] : [workerCommand]),
|
|
1562
|
-
],
|
|
1563
|
-
{ stdout: "pipe", stderr: "pipe" },
|
|
1564
|
-
);
|
|
1590
|
+
const split = executeTeamTmuxMutation(input.config, {
|
|
1591
|
+
type: "split",
|
|
1592
|
+
direction: "-v",
|
|
1593
|
+
target: splitTarget,
|
|
1594
|
+
cwd: workerCwd,
|
|
1595
|
+
...(useSendKeysFallback ? {} : { command: workerCommand }),
|
|
1596
|
+
});
|
|
1565
1597
|
if (split.exitCode !== 0)
|
|
1566
1598
|
throw new Error(split.stderr.toString().trim() || `memory_guard_split_failed:${input.worker.id}`);
|
|
1567
1599
|
const newPaneId = split.stdout.toString().trim().split(/\r?\n/)[0]?.trim() ?? "";
|
|
1568
1600
|
if (!newPaneId.startsWith("%")) throw new Error(`memory_guard_split_missing_pane:${input.worker.id}`);
|
|
1569
1601
|
if (useSendKeysFallback) {
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1602
|
+
executeTeamTmuxMutation(input.config, {
|
|
1603
|
+
type: "literal-send",
|
|
1604
|
+
paneId: newPaneId,
|
|
1605
|
+
text: workerCommand,
|
|
1606
|
+
deferredProof: "worker-startup-ack",
|
|
1573
1607
|
});
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1608
|
+
executeTeamTmuxMutation(input.config, {
|
|
1609
|
+
type: "key-send",
|
|
1610
|
+
paneId: newPaneId,
|
|
1611
|
+
key: "Enter",
|
|
1612
|
+
deferredProof: "worker-startup-ack",
|
|
1577
1613
|
});
|
|
1578
1614
|
}
|
|
1579
1615
|
const startupDeadline = Date.now() + input.startupAckTimeoutMs;
|
|
@@ -1591,36 +1627,24 @@ async function relaunchWorkerPaneForMemoryGuard(input: {
|
|
|
1591
1627
|
// The successor may still be publishing its generation-bound ACK.
|
|
1592
1628
|
}
|
|
1593
1629
|
if (Date.now() >= startupDeadline) {
|
|
1594
|
-
|
|
1595
|
-
stdout: "ignore",
|
|
1596
|
-
stderr: "ignore",
|
|
1597
|
-
});
|
|
1630
|
+
executeTeamTmuxMutation(input.config, { type: "kill-pane", paneId: newPaneId });
|
|
1598
1631
|
throw new Error(`memory_guard_successor_startup_timeout:${input.worker.id}`);
|
|
1599
1632
|
}
|
|
1600
1633
|
await Bun.sleep(50);
|
|
1601
1634
|
}
|
|
1602
1635
|
const successorPane = probePaneTeamTarget(input.config, newPaneId);
|
|
1603
1636
|
if (!successorPane.exists || !successorPane.belongsToTeamTarget) {
|
|
1604
|
-
|
|
1605
|
-
stdout: "ignore",
|
|
1606
|
-
stderr: "ignore",
|
|
1607
|
-
});
|
|
1637
|
+
executeTeamTmuxMutation(input.config, { type: "kill-pane", paneId: newPaneId });
|
|
1608
1638
|
throw new Error(`memory_guard_successor_pane_unavailable:${input.worker.id}`);
|
|
1609
1639
|
}
|
|
1610
1640
|
if (input.worker.pane_id) {
|
|
1611
1641
|
const oldPane = probePaneTeamTarget(input.config, input.worker.pane_id);
|
|
1612
1642
|
if (oldPane.exists && !oldPane.belongsToTeamTarget) {
|
|
1613
|
-
|
|
1614
|
-
stdout: "ignore",
|
|
1615
|
-
stderr: "ignore",
|
|
1616
|
-
});
|
|
1643
|
+
executeTeamTmuxMutation(input.config, { type: "kill-pane", paneId: newPaneId });
|
|
1617
1644
|
throw new Error(`memory_guard_old_pane_outside_team_target:${input.worker.id}`);
|
|
1618
1645
|
}
|
|
1619
1646
|
}
|
|
1620
|
-
|
|
1621
|
-
stdout: "ignore",
|
|
1622
|
-
stderr: "ignore",
|
|
1623
|
-
});
|
|
1647
|
+
executeTeamTmuxMutation(input.config, { type: "layout", target: input.config.tmux_target, layout: "main-vertical" });
|
|
1624
1648
|
return newPaneId;
|
|
1625
1649
|
}
|
|
1626
1650
|
|
|
@@ -1953,10 +1977,7 @@ async function applyWorkerMemoryGuardUnlocked(input: {
|
|
|
1953
1977
|
postAckAuthority.claim.leased_until !== task?.claim?.leased_until ||
|
|
1954
1978
|
Date.parse(postAckAuthority.claim.leased_until) <= currentTimeMs()
|
|
1955
1979
|
) {
|
|
1956
|
-
|
|
1957
|
-
stdout: "ignore",
|
|
1958
|
-
stderr: "ignore",
|
|
1959
|
-
});
|
|
1980
|
+
executeTeamTmuxMutation(config, { type: "kill-pane", paneId: newPaneId });
|
|
1960
1981
|
await restorePredecessorStartupState();
|
|
1961
1982
|
return {
|
|
1962
1983
|
ok: true,
|
|
@@ -1967,10 +1988,7 @@ async function applyWorkerMemoryGuardUnlocked(input: {
|
|
|
1967
1988
|
}
|
|
1968
1989
|
const successorPane = probePaneTeamTarget(config, newPaneId);
|
|
1969
1990
|
if (!successorPane.exists || !successorPane.belongsToTeamTarget || !successorPane.pid) {
|
|
1970
|
-
|
|
1971
|
-
stdout: "ignore",
|
|
1972
|
-
stderr: "ignore",
|
|
1973
|
-
});
|
|
1991
|
+
executeTeamTmuxMutation(config, { type: "kill-pane", paneId: newPaneId });
|
|
1974
1992
|
await restorePredecessorStartupState();
|
|
1975
1993
|
return {
|
|
1976
1994
|
ok: true,
|
|
@@ -2000,10 +2018,7 @@ async function applyWorkerMemoryGuardUnlocked(input: {
|
|
|
2000
2018
|
updated_at: nowIso,
|
|
2001
2019
|
};
|
|
2002
2020
|
const rollbackReplacement = async (): Promise<void> => {
|
|
2003
|
-
|
|
2004
|
-
stdout: "ignore",
|
|
2005
|
-
stderr: "ignore",
|
|
2006
|
-
});
|
|
2021
|
+
executeTeamTmuxMutation(config, { type: "kill-pane", paneId: newPaneId });
|
|
2007
2022
|
if (previousHeartbeat === undefined) await fs.rm(heartbeatPath, { force: true });
|
|
2008
2023
|
else await Bun.write(heartbeatPath, previousHeartbeat);
|
|
2009
2024
|
await syncTeamConfigAndManifest(dir, config);
|
|
@@ -2022,12 +2037,7 @@ async function applyWorkerMemoryGuardUnlocked(input: {
|
|
|
2022
2037
|
if (!cutoverPane.exists || !cutoverPane.belongsToTeamTarget || cutoverPane.pid !== successorHeartbeat.pid)
|
|
2023
2038
|
throw new Error(`memory_guard_successor_pane_changed:${worker.id}`);
|
|
2024
2039
|
if (worker.pane_id && !config.dry_run) {
|
|
2025
|
-
|
|
2026
|
-
stdout: "ignore",
|
|
2027
|
-
stderr: "pipe",
|
|
2028
|
-
});
|
|
2029
|
-
if (killed.exitCode !== 0 && probePaneTeamTarget(config, worker.pane_id).exists)
|
|
2030
|
-
throw new Error(killed.stderr.toString().trim() || `memory_guard_kill_failed:${worker.id}`);
|
|
2040
|
+
executeTeamTmuxMutation(config, { type: "kill-pane", paneId: worker.pane_id });
|
|
2031
2041
|
}
|
|
2032
2042
|
} catch (error) {
|
|
2033
2043
|
await rollbackReplacement();
|
|
@@ -2138,6 +2148,7 @@ const workerRuntime: GjcTeamWorkerRuntime = {
|
|
|
2138
2148
|
workerDir,
|
|
2139
2149
|
readJson: readJsonFile,
|
|
2140
2150
|
writeJson: writeJsonFile,
|
|
2151
|
+
withTaskMutation: (dir, fn) => withGjcTeamTaskMutation(taskStore(dir), fn),
|
|
2141
2152
|
appendEvent,
|
|
2142
2153
|
now,
|
|
2143
2154
|
nowMs: currentTimeMs,
|
|
@@ -2505,57 +2516,86 @@ function buildTeamTmuxLeaderRequirementMessage(detail?: string): string {
|
|
|
2505
2516
|
const suffix = detail?.trim() ? `:${detail.trim()}` : "";
|
|
2506
2517
|
return `gjc_team_requires_tmux_leader: start a tmux session first (run \`gjc --tmux\`, or launch tmux yourself), then run \`gjc team ...\` inside it, or use \`gjc team --dry-run\` for state-only smoke tests${suffix}`;
|
|
2507
2518
|
}
|
|
2508
|
-
function
|
|
2519
|
+
function providerExecutableArgv(authority: ProviderAuthority): string[] {
|
|
2520
|
+
if (
|
|
2521
|
+
process.platform === "win32" &&
|
|
2522
|
+
authority.kind === "native-tmux" &&
|
|
2523
|
+
path.isAbsolute(authority.command) &&
|
|
2524
|
+
path.extname(authority.command) === ""
|
|
2525
|
+
)
|
|
2526
|
+
return [
|
|
2527
|
+
path.join(process.env.ProgramFiles ?? "C:\\Program Files", "Git", "bin", "bash.exe"),
|
|
2528
|
+
authority.command.replaceAll("\\", "/"),
|
|
2529
|
+
];
|
|
2530
|
+
return [authority.command];
|
|
2531
|
+
}
|
|
2532
|
+
function readGjcTmuxProfileValue(authority: ProviderAuthority, sessionName: string): string {
|
|
2509
2533
|
const result = Bun.spawnSync(
|
|
2510
|
-
[
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2534
|
+
[
|
|
2535
|
+
...providerExecutableArgv(authority),
|
|
2536
|
+
...buildTmuxProviderCommand(authority, "show-options", [
|
|
2537
|
+
"-qv",
|
|
2538
|
+
"-t",
|
|
2539
|
+
buildGjcTmuxExactOptionTarget(sessionName, { binary: authority.binary }),
|
|
2540
|
+
GJC_TMUX_PROFILE_OPTION,
|
|
2541
|
+
]),
|
|
2542
|
+
],
|
|
2543
|
+
{ stdout: "pipe", stderr: "pipe" },
|
|
2515
2544
|
);
|
|
2516
2545
|
if (result.exitCode !== 0) return "";
|
|
2517
2546
|
return result.stdout.toString().trim();
|
|
2518
2547
|
}
|
|
2519
2548
|
|
|
2520
|
-
function tagTmuxSessionAsGjcLeader(
|
|
2549
|
+
function tagTmuxSessionAsGjcLeader(authority: ProviderAuthority, sessionName: string): boolean {
|
|
2550
|
+
assertGjcTmuxMutationAuthoritySync(authority);
|
|
2521
2551
|
const result = Bun.spawnSync(
|
|
2522
2552
|
[
|
|
2523
|
-
|
|
2524
|
-
"set-option",
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2553
|
+
...providerExecutableArgv(authority),
|
|
2554
|
+
...buildTmuxProviderCommand(authority, "set-option", [
|
|
2555
|
+
"-t",
|
|
2556
|
+
buildGjcTmuxExactOptionTarget(sessionName, { binary: authority.binary }),
|
|
2557
|
+
GJC_TMUX_PROFILE_OPTION,
|
|
2558
|
+
GJC_TMUX_PROFILE_VALUE,
|
|
2559
|
+
]),
|
|
2529
2560
|
],
|
|
2530
|
-
{
|
|
2531
|
-
stdout: "pipe",
|
|
2532
|
-
stderr: "pipe",
|
|
2533
|
-
},
|
|
2561
|
+
{ stdout: "pipe", stderr: "pipe" },
|
|
2534
2562
|
);
|
|
2535
|
-
|
|
2563
|
+
if (result.exitCode !== 0) return false;
|
|
2564
|
+
assertGjcTmuxMutationAuthoritySync(authority);
|
|
2565
|
+
return readGjcTmuxProfileValue(authority, sessionName) === GJC_TMUX_PROFILE_VALUE;
|
|
2536
2566
|
}
|
|
2537
2567
|
|
|
2538
2568
|
function readCurrentTmuxLeaderContext(
|
|
2539
2569
|
tmuxCommand: string,
|
|
2540
2570
|
env: NodeJS.ProcessEnv,
|
|
2541
|
-
|
|
2571
|
+
authority: ProviderAuthority,
|
|
2572
|
+
verifyProfile = true,
|
|
2542
2573
|
): GjcTmuxLeaderContext {
|
|
2543
|
-
if (Bun.which(tmuxCommand) === null)
|
|
2574
|
+
if (!path.isAbsolute(tmuxCommand) && Bun.which(tmuxCommand) === null)
|
|
2544
2575
|
throw new Error(buildTeamTmuxLeaderRequirementMessage(`tmux_not_installed:${tmuxCommand}`));
|
|
2545
2576
|
// Prefer the explicit GJC-managed session name propagated by `gjc --tmux`
|
|
2546
2577
|
// (GJC_TMUX_ACTIVE_SESSION). Under psmux on Windows the inherited TMUX_PANE
|
|
2547
2578
|
// can resolve to the wrong/default session, so querying the tagged session
|
|
2548
2579
|
// by name is authoritative for GJC-launched leaders. Fall back to TMUX_PANE,
|
|
2549
2580
|
// then to the ambient session, to keep native tmux/WSL flows unchanged.
|
|
2550
|
-
const activeSession =
|
|
2551
|
-
|
|
2581
|
+
const activeSession =
|
|
2582
|
+
authority.kind === "windows-psmux" || env.TMUX_PANE?.trim()
|
|
2583
|
+
? env[GJC_TMUX_ACTIVE_SESSION_ENV]?.trim()
|
|
2584
|
+
: undefined;
|
|
2585
|
+
const displayTarget = activeSession
|
|
2586
|
+
? buildGjcTmuxExactOptionTarget(activeSession, { env, binary: authority.binary })
|
|
2587
|
+
: env.TMUX_PANE?.trim();
|
|
2552
2588
|
const args = displayTarget
|
|
2553
2589
|
? ["display-message", "-p", "-t", displayTarget, "#S:#I #{pane_id}"]
|
|
2554
2590
|
: ["display-message", "-p", "#S:#I #{pane_id}"];
|
|
2555
|
-
const result = Bun.spawnSync(
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
2591
|
+
const result = Bun.spawnSync(
|
|
2592
|
+
[...providerExecutableArgv(authority), ...buildTmuxProviderCommand(authority, args[0]!, args.slice(1))],
|
|
2593
|
+
{
|
|
2594
|
+
stdout: "pipe",
|
|
2595
|
+
env,
|
|
2596
|
+
stderr: "pipe",
|
|
2597
|
+
},
|
|
2598
|
+
);
|
|
2559
2599
|
if (result.exitCode !== 0) {
|
|
2560
2600
|
// Distinguish "you are not inside any tmux session" from a genuine tmux
|
|
2561
2601
|
// query failure so the caller gets actionable guidance instead of raw
|
|
@@ -2572,7 +2612,7 @@ function readCurrentTmuxLeaderContext(
|
|
|
2572
2612
|
const [sessionName = "", windowIndex = ""] = sessionAndWindow.split(":");
|
|
2573
2613
|
if (!sessionName || !windowIndex || !leaderPaneId.startsWith("%"))
|
|
2574
2614
|
throw new Error(buildTeamTmuxLeaderRequirementMessage(`invalid_tmux_context:${result.stdout.toString().trim()}`));
|
|
2575
|
-
if (
|
|
2615
|
+
if (verifyProfile && readGjcTmuxProfileValue(authority, sessionName) !== GJC_TMUX_PROFILE_VALUE) {
|
|
2576
2616
|
// Adopt any real tmux leader as a GJC team leader — including a session
|
|
2577
2617
|
// the user created outside `gjc --tmux` — by writing GJC's @gjc-profile
|
|
2578
2618
|
// ownership tag and reading it back. A provider that round-trips tmux
|
|
@@ -2580,8 +2620,8 @@ function readCurrentTmuxLeaderContext(
|
|
|
2580
2620
|
// not (e.g. psmux on Windows) drops it, so the readback still fails and
|
|
2581
2621
|
// the leader is rejected as unmanaged. This also self-heals a genuine
|
|
2582
2622
|
// `gjc --tmux` pane that lost its @gjc-profile tag mid-startup.
|
|
2583
|
-
const tagged = tagTmuxSessionAsGjcLeader(
|
|
2584
|
-
if (!tagged || readGjcTmuxProfileValue(
|
|
2623
|
+
const tagged = tagTmuxSessionAsGjcLeader(authority, sessionName);
|
|
2624
|
+
if (!tagged || readGjcTmuxProfileValue(authority, sessionName) !== GJC_TMUX_PROFILE_VALUE)
|
|
2585
2625
|
throw new Error(
|
|
2586
2626
|
buildTeamTmuxLeaderRequirementMessage(
|
|
2587
2627
|
`unmanaged_tmux_session:${sessionName} — ${buildGjcTmuxUntaggedSessionHint(tmuxCommand)}`,
|
|
@@ -2603,7 +2643,26 @@ export function probeGjcTeamAvailability(
|
|
|
2603
2643
|
env: NodeJS.ProcessEnv = process.env,
|
|
2604
2644
|
): { available: true } | { available: false; reason: string } {
|
|
2605
2645
|
try {
|
|
2606
|
-
|
|
2646
|
+
const stateDir = env[GJC_TMUX_OWNER_STATE_DIR_ENV]?.trim();
|
|
2647
|
+
const sessionId = env[GJC_COORDINATOR_SESSION_ID_ENV]?.trim();
|
|
2648
|
+
const generation = env[GJC_TMUX_OWNER_GENERATION_ENV]?.trim();
|
|
2649
|
+
const authority =
|
|
2650
|
+
stateDir && sessionId && generation && hasGjcTmuxProviderAuthoritySync({ stateDir, sessionId, generation })
|
|
2651
|
+
? readGjcTmuxProviderAuthoritySync({ stateDir, sessionId, generation })
|
|
2652
|
+
: null;
|
|
2653
|
+
const provider = authority ?? resolveGjcTmuxProviderContext({ env });
|
|
2654
|
+
if (provider.binary.isPsmux && !authority) throw new Error("gjc_team_tmux_provider_authority_unavailable");
|
|
2655
|
+
readCurrentTmuxLeaderContext(
|
|
2656
|
+
provider.command,
|
|
2657
|
+
env,
|
|
2658
|
+
authority ??
|
|
2659
|
+
bindGjcTmuxProviderAuthority(provider, {
|
|
2660
|
+
stateDir: process.cwd(),
|
|
2661
|
+
sessionId: "team-probe",
|
|
2662
|
+
generation: "probe",
|
|
2663
|
+
}),
|
|
2664
|
+
false,
|
|
2665
|
+
);
|
|
2607
2666
|
return { available: true };
|
|
2608
2667
|
} catch (error) {
|
|
2609
2668
|
return { available: false, reason: error instanceof Error ? error.message : String(error) };
|
|
@@ -2676,8 +2735,12 @@ export function resolveGjcWorkerCommand(
|
|
|
2676
2735
|
}
|
|
2677
2736
|
export { buildWorkerCommand } from "./team-launch";
|
|
2678
2737
|
|
|
2679
|
-
function shouldDispatchWorkerWithSendKeys(tmuxCommand: string,
|
|
2680
|
-
|
|
2738
|
+
function shouldDispatchWorkerWithSendKeys(tmuxCommand: string, providerGeneration?: string): boolean {
|
|
2739
|
+
const command = path
|
|
2740
|
+
.basename(tmuxCommand)
|
|
2741
|
+
.toLowerCase()
|
|
2742
|
+
.replace(/\.exe$/, "");
|
|
2743
|
+
return Boolean(providerGeneration) || command === "psmux" || command === "pmux";
|
|
2681
2744
|
}
|
|
2682
2745
|
|
|
2683
2746
|
interface GjcTeamInitialLane {
|
|
@@ -2768,6 +2831,181 @@ function buildInitialTasks(task: string, workers: GjcTeamWorker[]): GjcTeamTask[
|
|
|
2768
2831
|
}));
|
|
2769
2832
|
}
|
|
2770
2833
|
|
|
2834
|
+
function configuredWindowsTmuxCommandIsNative(command: string): boolean {
|
|
2835
|
+
const normalized = command.trim().replace(/\\/g, "/");
|
|
2836
|
+
const basename = normalized.slice(normalized.lastIndexOf("/") + 1).toLowerCase();
|
|
2837
|
+
return basename === "tmux" || basename === "tmux.exe";
|
|
2838
|
+
}
|
|
2839
|
+
|
|
2840
|
+
function teamProviderAuthority(config: GjcTeamConfig): ProviderAuthority {
|
|
2841
|
+
if (
|
|
2842
|
+
config.tmux_provider_generation &&
|
|
2843
|
+
hasGjcTmuxProviderAuthoritySync({
|
|
2844
|
+
stateDir: config.tmux_provider_state_dir ?? config.state_root,
|
|
2845
|
+
sessionId: config.tmux_provider_session_id ?? config.team_name,
|
|
2846
|
+
generation: config.tmux_provider_generation,
|
|
2847
|
+
})
|
|
2848
|
+
)
|
|
2849
|
+
return readGjcTmuxProviderAuthoritySync({
|
|
2850
|
+
stateDir: config.tmux_provider_state_dir ?? config.state_root,
|
|
2851
|
+
sessionId: config.tmux_provider_session_id ?? config.team_name,
|
|
2852
|
+
generation: config.tmux_provider_generation,
|
|
2853
|
+
});
|
|
2854
|
+
if (config.tmux_provider_generation && !config.dry_run)
|
|
2855
|
+
throw new Error("gjc_team_tmux_provider_authority_unavailable");
|
|
2856
|
+
|
|
2857
|
+
const binary = resolveGjcTmuxBinary({
|
|
2858
|
+
env: {
|
|
2859
|
+
...process.env,
|
|
2860
|
+
GJC_TMUX_COMMAND: config.tmux_command,
|
|
2861
|
+
GJC_TEAM_TMUX_COMMAND: config.tmux_command,
|
|
2862
|
+
},
|
|
2863
|
+
});
|
|
2864
|
+
const context = resolveGjcTmuxProviderContext({ binary, platform: config.platform ?? process.platform });
|
|
2865
|
+
if (context.kind === "windows-psmux") throw new Error("gjc_team_tmux_provider_authority_unavailable");
|
|
2866
|
+
if ((config.platform ?? process.platform) === "win32" && !configuredWindowsTmuxCommandIsNative(config.tmux_command))
|
|
2867
|
+
throw new Error("gjc_team_tmux_provider_ambiguous");
|
|
2868
|
+
return bindGjcTmuxProviderAuthority(context, {
|
|
2869
|
+
stateDir: config.state_root,
|
|
2870
|
+
sessionId: config.team_name,
|
|
2871
|
+
generation: "native-tmux",
|
|
2872
|
+
});
|
|
2873
|
+
}
|
|
2874
|
+
|
|
2875
|
+
function teamTmuxArgs(config: GjcTeamConfig, command: string, args: readonly string[] = []): string[] {
|
|
2876
|
+
const authority = teamProviderAuthority(config);
|
|
2877
|
+
return [...providerExecutableArgv(authority), ...buildTmuxProviderCommand(authority, command, args)];
|
|
2878
|
+
}
|
|
2879
|
+
|
|
2880
|
+
type TeamTmuxMutation =
|
|
2881
|
+
| { type: "split"; direction: string; target: string; cwd: string; command?: string }
|
|
2882
|
+
| {
|
|
2883
|
+
type: "literal-send";
|
|
2884
|
+
paneId: string;
|
|
2885
|
+
text: string;
|
|
2886
|
+
deferredProof: "worker-startup-ack" | "continuation-outcome";
|
|
2887
|
+
}
|
|
2888
|
+
| { type: "key-send"; paneId: string; key: string; deferredProof: "worker-startup-ack" | "continuation-outcome" }
|
|
2889
|
+
| { type: "layout"; target: string; layout: string }
|
|
2890
|
+
| { type: "set-window-option"; target: string; name: string; value: string }
|
|
2891
|
+
| { type: "kill-pane"; paneId: string }
|
|
2892
|
+
| { type: "profile-option"; target: string; name: string; value: string }
|
|
2893
|
+
| { type: "profile-window-option"; target: string; name: string; value: string };
|
|
2894
|
+
|
|
2895
|
+
function readTeamTmuxValue(
|
|
2896
|
+
config: GjcTeamConfig,
|
|
2897
|
+
command: "show-options" | "show-window-options",
|
|
2898
|
+
target: string,
|
|
2899
|
+
name: string,
|
|
2900
|
+
): string | undefined {
|
|
2901
|
+
const result = Bun.spawnSync(teamTmuxArgs(config, command, ["-qv", "-t", target, name]), {
|
|
2902
|
+
stdout: "pipe",
|
|
2903
|
+
stderr: "pipe",
|
|
2904
|
+
});
|
|
2905
|
+
if (result.exitCode !== 0) return undefined;
|
|
2906
|
+
return result.stdout.toString().trim();
|
|
2907
|
+
}
|
|
2908
|
+
function teamTargetExists(config: GjcTeamConfig, target: string): boolean {
|
|
2909
|
+
const result = Bun.spawnSync(teamTmuxArgs(config, "display-message", ["-p", "-t", target, "#S:#I"]), {
|
|
2910
|
+
stdout: "pipe",
|
|
2911
|
+
stderr: "pipe",
|
|
2912
|
+
});
|
|
2913
|
+
if (result.exitCode !== 0) return false;
|
|
2914
|
+
const observed = result.stdout.toString().trim().split(/\s+/)[0];
|
|
2915
|
+
const unprefixed = target.startsWith("=") ? target.slice(1) : target;
|
|
2916
|
+
const expected = unprefixed.endsWith(":") ? `${unprefixed}0` : unprefixed;
|
|
2917
|
+
return observed === expected || (!expected.includes(":") && observed?.startsWith(`${expected}:`) === true);
|
|
2918
|
+
}
|
|
2919
|
+
|
|
2920
|
+
function assertTeamTmuxMutationPreproof(config: GjcTeamConfig, operation: TeamTmuxMutation): void {
|
|
2921
|
+
if (operation.type === "split") {
|
|
2922
|
+
if (
|
|
2923
|
+
(operation.target !== config.tmux_target || !teamTargetExists(config, operation.target)) &&
|
|
2924
|
+
!probePaneTeamTarget(config, operation.target).belongsToTeamTarget
|
|
2925
|
+
)
|
|
2926
|
+
throw new Error("tmux_split_preproof_failed");
|
|
2927
|
+
return;
|
|
2928
|
+
}
|
|
2929
|
+
if (operation.type === "layout" || operation.type === "set-window-option") {
|
|
2930
|
+
if (operation.target !== config.tmux_target || !teamTargetExists(config, operation.target))
|
|
2931
|
+
throw new Error(`tmux_${operation.type}_session_preproof_failed`);
|
|
2932
|
+
return;
|
|
2933
|
+
}
|
|
2934
|
+
if (operation.type === "profile-option" || operation.type === "profile-window-option") {
|
|
2935
|
+
if (operation.target !== config.tmux_target || !teamTargetExists(config, operation.target))
|
|
2936
|
+
throw new Error(`tmux_${operation.type}_session_preproof_failed`);
|
|
2937
|
+
return;
|
|
2938
|
+
}
|
|
2939
|
+
const pane = probePaneTeamTarget(config, operation.paneId);
|
|
2940
|
+
if (operation.type === "kill-pane" ? !pane.exists || !pane.belongsToTeamTarget : !pane.belongsToTeamTarget)
|
|
2941
|
+
throw new Error(`tmux_${operation.type}_preproof_failed`);
|
|
2942
|
+
// Delivery is proven asynchronously by the generation-bound worker startup ACK
|
|
2943
|
+
// or continuation outcome named in the closed operation variant. Exit status
|
|
2944
|
+
// alone is never treated as delivery proof.
|
|
2945
|
+
}
|
|
2946
|
+
|
|
2947
|
+
function executeTeamTmuxMutation(
|
|
2948
|
+
config: GjcTeamConfig,
|
|
2949
|
+
operation: TeamTmuxMutation,
|
|
2950
|
+
): Bun.SyncSubprocess<"pipe", "pipe"> {
|
|
2951
|
+
const authority = teamProviderAuthority(config);
|
|
2952
|
+
assertTeamTmuxMutationPreproof(config, operation);
|
|
2953
|
+
const args =
|
|
2954
|
+
operation.type === "split"
|
|
2955
|
+
? [
|
|
2956
|
+
"split-window",
|
|
2957
|
+
operation.direction,
|
|
2958
|
+
"-t",
|
|
2959
|
+
operation.target,
|
|
2960
|
+
"-d",
|
|
2961
|
+
"-P",
|
|
2962
|
+
"-F",
|
|
2963
|
+
"#{pane_id}",
|
|
2964
|
+
"-c",
|
|
2965
|
+
operation.cwd,
|
|
2966
|
+
...(operation.command ? [operation.command] : []),
|
|
2967
|
+
]
|
|
2968
|
+
: operation.type === "literal-send"
|
|
2969
|
+
? ["send-keys", "-l", "-t", operation.paneId, operation.text]
|
|
2970
|
+
: operation.type === "key-send"
|
|
2971
|
+
? ["send-keys", "-t", operation.paneId, operation.key]
|
|
2972
|
+
: operation.type === "layout"
|
|
2973
|
+
? ["select-layout", "-t", operation.target, operation.layout]
|
|
2974
|
+
: operation.type === "set-window-option" || operation.type === "profile-window-option"
|
|
2975
|
+
? ["set-window-option", "-t", operation.target, operation.name, operation.value]
|
|
2976
|
+
: operation.type === "kill-pane"
|
|
2977
|
+
? ["kill-pane", "-t", operation.paneId]
|
|
2978
|
+
: ["set-option", "-t", operation.target, operation.name, operation.value];
|
|
2979
|
+
assertGjcTmuxMutationAuthoritySync(authority);
|
|
2980
|
+
const result = Bun.spawnSync(
|
|
2981
|
+
[...providerExecutableArgv(authority), ...buildTmuxProviderCommand(authority, args[0]!, args.slice(1))],
|
|
2982
|
+
{ stdout: "pipe", stderr: "pipe" },
|
|
2983
|
+
);
|
|
2984
|
+
if (result.exitCode !== 0 && operation.type !== "kill-pane")
|
|
2985
|
+
throw new Error(result.stderr.toString().trim() || `tmux_${operation.type}_failed`);
|
|
2986
|
+
assertGjcTmuxMutationAuthoritySync(authority);
|
|
2987
|
+
if (operation.type === "split") {
|
|
2988
|
+
const paneId = result.stdout.toString().trim().split(/\r?\n/)[0]?.trim() ?? "";
|
|
2989
|
+
if (!paneId.startsWith("%") || !probePaneTeamTarget(config, paneId).belongsToTeamTarget)
|
|
2990
|
+
throw new Error("tmux_split_postproof_failed");
|
|
2991
|
+
} else if (operation.type === "kill-pane") {
|
|
2992
|
+
if (probePaneTeamTarget(config, operation.paneId).exists) throw new Error("tmux_kill_postproof_failed");
|
|
2993
|
+
} else if (operation.type === "layout") {
|
|
2994
|
+
const layout = Bun.spawnSync(
|
|
2995
|
+
teamTmuxArgs(config, "display-message", ["-p", "-t", operation.target, "#{window_layout}"]),
|
|
2996
|
+
{ stdout: "pipe", stderr: "pipe" },
|
|
2997
|
+
);
|
|
2998
|
+
if (layout.exitCode !== 0 || layout.stdout.toString().trim() !== operation.layout)
|
|
2999
|
+
throw new Error("tmux_layout_postproof_failed");
|
|
3000
|
+
} else if (operation.type === "set-window-option" || operation.type === "profile-window-option") {
|
|
3001
|
+
if (readTeamTmuxValue(config, "show-window-options", operation.target, operation.name) !== operation.value)
|
|
3002
|
+
throw new Error("tmux_window_option_postproof_failed");
|
|
3003
|
+
} else if (operation.type === "profile-option") {
|
|
3004
|
+
if (readTeamTmuxValue(config, "show-options", operation.target, operation.name) !== operation.value)
|
|
3005
|
+
throw new Error("tmux_profile_option_postproof_failed");
|
|
3006
|
+
}
|
|
3007
|
+
return result;
|
|
3008
|
+
}
|
|
2771
3009
|
async function startTmuxSession(
|
|
2772
3010
|
config: GjcTeamConfig,
|
|
2773
3011
|
dir: string,
|
|
@@ -2789,24 +3027,17 @@ async function startTmuxSession(
|
|
|
2789
3027
|
worker.index === 1 ? config.tmux_target : (rightStackRootPaneId ?? config.tmux_target);
|
|
2790
3028
|
const workerCommand = buildWorkerCommand(config, worker, process.platform, undefined, env);
|
|
2791
3029
|
const workerCwd = worker.worktree_path ?? config.leader.cwd;
|
|
2792
|
-
const useSendKeysFallback = shouldDispatchWorkerWithSendKeys(
|
|
2793
|
-
const splitArgs = [
|
|
3030
|
+
const useSendKeysFallback = shouldDispatchWorkerWithSendKeys(
|
|
2794
3031
|
config.tmux_command,
|
|
2795
|
-
|
|
2796
|
-
|
|
2797
|
-
|
|
2798
|
-
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
|
|
2802
|
-
|
|
2803
|
-
|
|
2804
|
-
workerCwd,
|
|
2805
|
-
...(useSendKeysFallback ? [] : [workerCommand]),
|
|
2806
|
-
];
|
|
2807
|
-
const split: Bun.SyncSubprocess<"pipe", "pipe"> = Bun.spawnSync(splitArgs, { stdout: "pipe", stderr: "pipe" });
|
|
2808
|
-
if (split.exitCode !== 0)
|
|
2809
|
-
throw new Error(split.stderr.toString().trim() || `tmux_split_failed:${config.tmux_target}:${worker.id}`);
|
|
3032
|
+
config.tmux_provider_generation,
|
|
3033
|
+
);
|
|
3034
|
+
const split = executeTeamTmuxMutation(config, {
|
|
3035
|
+
type: "split",
|
|
3036
|
+
direction: splitDirection,
|
|
3037
|
+
target: splitTarget,
|
|
3038
|
+
cwd: workerCwd,
|
|
3039
|
+
...(useSendKeysFallback ? {} : { command: workerCommand }),
|
|
3040
|
+
});
|
|
2810
3041
|
const paneId: string = split.stdout.toString().trim().split(/\r?\n/)[0]?.trim() ?? "";
|
|
2811
3042
|
if (!paneId.startsWith("%")) throw new Error(`tmux_split_missing_pane:${config.tmux_target}:${worker.id}`);
|
|
2812
3043
|
rollbackPaneIds.push(paneId);
|
|
@@ -2823,53 +3054,71 @@ async function startTmuxSession(
|
|
|
2823
3054
|
// "Enter" as literal text too. Sending the body in literal mode first and
|
|
2824
3055
|
// the Enter keypress second keeps the body verbatim while still submitting
|
|
2825
3056
|
// the prompt as a keystroke.
|
|
2826
|
-
|
|
2827
|
-
|
|
2828
|
-
|
|
3057
|
+
executeTeamTmuxMutation(config, {
|
|
3058
|
+
type: "literal-send",
|
|
3059
|
+
paneId,
|
|
3060
|
+
text: workerCommand,
|
|
3061
|
+
deferredProof: "worker-startup-ack",
|
|
2829
3062
|
});
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
3063
|
+
executeTeamTmuxMutation(config, {
|
|
3064
|
+
type: "key-send",
|
|
3065
|
+
paneId,
|
|
3066
|
+
key: "Enter",
|
|
3067
|
+
deferredProof: "worker-startup-ack",
|
|
2833
3068
|
});
|
|
2834
|
-
// void-cast the exit code so the linter does not flag an unused expression;
|
|
2835
|
-
// the value is intentionally discarded here because the actual spawn outcome
|
|
2836
|
-
// is recovered by the leader through the worker startup-ack watcher, not via
|
|
2837
|
-
// the spawn exit code.
|
|
2838
|
-
void sendKeys.exitCode;
|
|
2839
3069
|
}
|
|
2840
3070
|
}
|
|
2841
|
-
|
|
2842
|
-
stdout: "ignore",
|
|
2843
|
-
stderr: "ignore",
|
|
2844
|
-
});
|
|
3071
|
+
executeTeamTmuxMutation(config, { type: "layout", target: config.tmux_target, layout: "main-vertical" });
|
|
2845
3072
|
const widthResult = Bun.spawnSync(
|
|
2846
|
-
|
|
3073
|
+
teamTmuxArgs(config, "display-message", ["-p", "-t", config.tmux_target, "#{window_width}"]),
|
|
2847
3074
|
{ stdout: "pipe", stderr: "ignore" },
|
|
2848
3075
|
);
|
|
2849
3076
|
const width = Number.parseInt(widthResult.stdout.toString().trim(), 10);
|
|
2850
3077
|
if (Number.isFinite(width) && width >= 40) {
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
|
|
2855
|
-
|
|
2856
|
-
config.tmux_target,
|
|
2857
|
-
"main-pane-width",
|
|
2858
|
-
String(Math.floor(width / 2)),
|
|
2859
|
-
],
|
|
2860
|
-
{ stdout: "ignore", stderr: "ignore" },
|
|
2861
|
-
);
|
|
2862
|
-
Bun.spawnSync([config.tmux_command, "select-layout", "-t", config.tmux_target, "main-vertical"], {
|
|
2863
|
-
stdout: "ignore",
|
|
2864
|
-
stderr: "ignore",
|
|
3078
|
+
executeTeamTmuxMutation(config, {
|
|
3079
|
+
type: "set-window-option",
|
|
3080
|
+
target: config.tmux_target,
|
|
3081
|
+
name: "main-pane-width",
|
|
3082
|
+
value: String(Math.floor(width / 2)),
|
|
2865
3083
|
});
|
|
3084
|
+
executeTeamTmuxMutation(config, { type: "layout", target: config.tmux_target, layout: "main-vertical" });
|
|
2866
3085
|
}
|
|
2867
|
-
const
|
|
2868
|
-
|
|
2869
|
-
target: config.tmux_target,
|
|
2870
|
-
cwd: config.leader.cwd,
|
|
3086
|
+
const profileCommands = buildGjcTmuxProfileCommands(
|
|
3087
|
+
config.tmux_target,
|
|
2871
3088
|
env,
|
|
3089
|
+
{},
|
|
3090
|
+
{
|
|
3091
|
+
tmuxCommand: config.tmux_command,
|
|
3092
|
+
},
|
|
3093
|
+
);
|
|
3094
|
+
const profileFailures = profileCommands.filter(command => {
|
|
3095
|
+
const [kind, targetFlag, target, name, value] = command.args;
|
|
3096
|
+
if (
|
|
3097
|
+
(kind !== "set-option" && kind !== "set-window-option") ||
|
|
3098
|
+
targetFlag !== "-t" ||
|
|
3099
|
+
!target ||
|
|
3100
|
+
!name ||
|
|
3101
|
+
value === undefined ||
|
|
3102
|
+
command.args.length !== 5
|
|
3103
|
+
)
|
|
3104
|
+
return true;
|
|
3105
|
+
try {
|
|
3106
|
+
executeTeamTmuxMutation(config, {
|
|
3107
|
+
type: kind === "set-option" ? "profile-option" : "profile-window-option",
|
|
3108
|
+
target,
|
|
3109
|
+
name,
|
|
3110
|
+
value,
|
|
3111
|
+
});
|
|
3112
|
+
return false;
|
|
3113
|
+
} catch {
|
|
3114
|
+
return true;
|
|
3115
|
+
}
|
|
2872
3116
|
});
|
|
3117
|
+
const profileResult = {
|
|
3118
|
+
skipped: false,
|
|
3119
|
+
commands: profileCommands,
|
|
3120
|
+
failures: profileFailures,
|
|
3121
|
+
};
|
|
2873
3122
|
await appendTelemetry(dir, {
|
|
2874
3123
|
type: "tmux_profile_applied",
|
|
2875
3124
|
message: profileResult.skipped
|
|
@@ -2891,11 +3140,13 @@ async function startTmuxSession(
|
|
|
2891
3140
|
});
|
|
2892
3141
|
return workers;
|
|
2893
3142
|
} catch (error) {
|
|
2894
|
-
for (const paneId of rollbackPaneIds)
|
|
2895
|
-
|
|
2896
|
-
|
|
2897
|
-
|
|
2898
|
-
|
|
3143
|
+
for (const paneId of rollbackPaneIds) {
|
|
3144
|
+
try {
|
|
3145
|
+
executeTeamTmuxMutation(config, { type: "kill-pane", paneId });
|
|
3146
|
+
} catch {
|
|
3147
|
+
// Preserve the original launch failure while making a best-effort cleanup.
|
|
3148
|
+
}
|
|
3149
|
+
}
|
|
2899
3150
|
throw error;
|
|
2900
3151
|
}
|
|
2901
3152
|
}
|
|
@@ -2905,7 +3156,7 @@ function probePaneTeamTarget(
|
|
|
2905
3156
|
): { exists: boolean; belongsToTeamTarget: boolean; pid?: number } {
|
|
2906
3157
|
if (paneId === config.leader.pane_id) return { exists: true, belongsToTeamTarget: false };
|
|
2907
3158
|
const result = Bun.spawnSync(
|
|
2908
|
-
|
|
3159
|
+
teamTmuxArgs(config, "display-message", ["-p", "-t", paneId, "#S:#I #{pane_id} #{pane_pid}"]),
|
|
2909
3160
|
{ stdout: "pipe", stderr: "ignore" },
|
|
2910
3161
|
);
|
|
2911
3162
|
if (result.exitCode !== 0) return { exists: false, belongsToTeamTarget: false };
|
|
@@ -2924,10 +3175,7 @@ function paneBelongsToTeamTarget(config: GjcTeamConfig, paneId: string): boolean
|
|
|
2924
3175
|
function killWorkerPanes(config: GjcTeamConfig): void {
|
|
2925
3176
|
for (const worker of config.workers)
|
|
2926
3177
|
if (worker.pane_id?.startsWith("%") && paneBelongsToTeamTarget(config, worker.pane_id))
|
|
2927
|
-
|
|
2928
|
-
stdout: "ignore",
|
|
2929
|
-
stderr: "ignore",
|
|
2930
|
-
});
|
|
3178
|
+
executeTeamTmuxMutation(config, { type: "kill-pane", paneId: worker.pane_id });
|
|
2931
3179
|
}
|
|
2932
3180
|
async function rollbackCreatedWorktrees(workers: GjcTeamWorker[]): Promise<void> {
|
|
2933
3181
|
for (const worker of workers.filter(worker => worker.worktree_created).reverse())
|
|
@@ -3870,6 +4118,11 @@ async function validateGjcContinuationEligibility(
|
|
|
3870
4118
|
staleMs: number,
|
|
3871
4119
|
env: NodeJS.ProcessEnv,
|
|
3872
4120
|
reservedHoldUntil?: string,
|
|
4121
|
+
// Revalidation runs after the dispatch fence has been released once for the
|
|
4122
|
+
// reservation write, so a non-claim task edit can legitimately land in between
|
|
4123
|
+
// and bump `version`. Claim identity — owner, token, lease — is what authorizes
|
|
4124
|
+
// continuation; a version bump alone is not a claim change.
|
|
4125
|
+
allowVersionDrift = false,
|
|
3873
4126
|
): Promise<string | null> {
|
|
3874
4127
|
const phase = await readContinuationJson<Record<string, unknown>>(path.join(dir, "phase.json"));
|
|
3875
4128
|
if (
|
|
@@ -3909,7 +4162,7 @@ async function validateGjcContinuationEligibility(
|
|
|
3909
4162
|
if (
|
|
3910
4163
|
!authority.valid ||
|
|
3911
4164
|
authority.task.id !== task.id ||
|
|
3912
|
-
authority.task.version !== task.version ||
|
|
4165
|
+
(!allowVersionDrift && authority.task.version !== task.version) ||
|
|
3913
4166
|
authority.task.claim?.owner !== task.claim?.owner ||
|
|
3914
4167
|
authority.task.claim?.token !== task.claim?.token ||
|
|
3915
4168
|
authority.task.claim?.leased_until !== task.claim?.leased_until
|
|
@@ -3920,10 +4173,63 @@ async function validateGjcContinuationEligibility(
|
|
|
3920
4173
|
if (!Number.isFinite(leaseUntil) || leaseUntil <= currentTimeMs()) return "invalid_or_expired_lease";
|
|
3921
4174
|
if (holdUntil !== undefined && (!Number.isFinite(holdUntil) || leaseUntil < holdUntil))
|
|
3922
4175
|
return "lease_does_not_cover_hold";
|
|
3923
|
-
if (reservedHoldUntil !== undefined && shouldDispatchWorkerWithSendKeys(config.tmux_command))
|
|
3924
|
-
return "unsupported_send_keys_transport";
|
|
3925
4176
|
return null;
|
|
3926
4177
|
}
|
|
4178
|
+
async function validateGjcContinuationAckAuthority(
|
|
4179
|
+
dir: string,
|
|
4180
|
+
config: GjcTeamConfig,
|
|
4181
|
+
worker: GjcTeamWorker,
|
|
4182
|
+
task: GjcTeamTask,
|
|
4183
|
+
heartbeatAt: string,
|
|
4184
|
+
staleMs: number,
|
|
4185
|
+
env: NodeJS.ProcessEnv,
|
|
4186
|
+
reservation: Record<string, unknown>,
|
|
4187
|
+
incident: string,
|
|
4188
|
+
attempt: number,
|
|
4189
|
+
): Promise<string | null> {
|
|
4190
|
+
let currentConfig: GjcTeamConfig;
|
|
4191
|
+
try {
|
|
4192
|
+
currentConfig = await readConfig(dir);
|
|
4193
|
+
} catch {
|
|
4194
|
+
return "invalid_config_authority";
|
|
4195
|
+
}
|
|
4196
|
+
try {
|
|
4197
|
+
assertGjcTmuxMutationAuthoritySync(teamProviderAuthority(currentConfig));
|
|
4198
|
+
} catch {
|
|
4199
|
+
return "provider_authority_changed";
|
|
4200
|
+
}
|
|
4201
|
+
const currentWorker = currentConfig.workers.find(candidate => candidate.id === worker.id);
|
|
4202
|
+
if (
|
|
4203
|
+
!currentWorker?.pane_id ||
|
|
4204
|
+
currentConfig.team_name !== config.team_name ||
|
|
4205
|
+
currentWorker.pane_id !== reservation.pane_id ||
|
|
4206
|
+
!isValidGjcContinuationReservation(
|
|
4207
|
+
reservation,
|
|
4208
|
+
incident,
|
|
4209
|
+
attempt,
|
|
4210
|
+
currentConfig,
|
|
4211
|
+
worker.id,
|
|
4212
|
+
task,
|
|
4213
|
+
task.claim!,
|
|
4214
|
+
heartbeatAt,
|
|
4215
|
+
currentWorker.pane_id,
|
|
4216
|
+
)
|
|
4217
|
+
)
|
|
4218
|
+
return "reservation_authority_changed";
|
|
4219
|
+
const reason = await validateGjcContinuationEligibility(
|
|
4220
|
+
dir,
|
|
4221
|
+
currentConfig,
|
|
4222
|
+
currentWorker,
|
|
4223
|
+
task,
|
|
4224
|
+
heartbeatAt,
|
|
4225
|
+
staleMs,
|
|
4226
|
+
env,
|
|
4227
|
+
);
|
|
4228
|
+
if (reason) return reason;
|
|
4229
|
+
const lifecycle = (await readLifecycleById(workerRuntime, dir, currentConfig))[worker.id];
|
|
4230
|
+
const incarnation = `${currentWorker.pane_id ?? ""}:${lifecycle?.started_at ?? lifecycle?.updated_at}`;
|
|
4231
|
+
return incarnation === reservation.worker_incarnation ? null : "worker_incarnation_changed";
|
|
4232
|
+
}
|
|
3927
4233
|
function normalizeGjcContinuationDispatchError(value: unknown, fallback: string, maxLength: number): string {
|
|
3928
4234
|
try {
|
|
3929
4235
|
const normalized = String(value ?? "")
|
|
@@ -4083,12 +4389,12 @@ async function continueStalledGjcTeamWorkers(
|
|
|
4083
4389
|
)
|
|
4084
4390
|
)
|
|
4085
4391
|
continue;
|
|
4086
|
-
if (
|
|
4087
|
-
|
|
4088
|
-
|
|
4089
|
-
)
|
|
4090
|
-
|
|
4091
|
-
const firstHold = Date.parse(String(
|
|
4392
|
+
if (!isValidGjcContinuationOutcome(firstOutcome, firstReservation, incident, 1)) continue;
|
|
4393
|
+
const firstAck = await readContinuationJson<Record<string, unknown>>(
|
|
4394
|
+
path.join(journalDir, "attempt-01.ack.json"),
|
|
4395
|
+
);
|
|
4396
|
+
if (!isValidGjcContinuationAck(firstAck, firstReservation, incident, 1)) continue;
|
|
4397
|
+
const firstHold = Date.parse(String(firstReservation.hold_until));
|
|
4092
4398
|
const leaseUntil = Date.parse(task.claim.leased_until);
|
|
4093
4399
|
if (
|
|
4094
4400
|
!Number.isFinite(firstHold) ||
|
|
@@ -4119,90 +4425,211 @@ async function continueStalledGjcTeamWorkers(
|
|
|
4119
4425
|
leased_until: task.claim.leased_until,
|
|
4120
4426
|
heartbeat_at: heartbeat.last_turn_at,
|
|
4121
4427
|
pane_id: worker.pane_id,
|
|
4428
|
+
worker_incarnation: `${worker.pane_id}:${lifecycle.started_at ?? lifecycle.updated_at}`,
|
|
4122
4429
|
tmux_target: config.tmux_target,
|
|
4123
4430
|
attempt,
|
|
4431
|
+
attempt_nonce: randomUUID(),
|
|
4124
4432
|
reserved_at: reservedAt,
|
|
4125
4433
|
hold_until: holdUntil,
|
|
4126
4434
|
prompt_version: 1,
|
|
4127
|
-
prompt_sha256:
|
|
4435
|
+
prompt_sha256: "",
|
|
4128
4436
|
dispatch_protocol: "tmux_command_sequence_v1",
|
|
4129
4437
|
};
|
|
4438
|
+
const continuationPrompt = buildGjcContinuationPrompt(reservation);
|
|
4439
|
+
reservation.prompt_sha256 = createHash("sha256").update(continuationPrompt).digest("hex");
|
|
4130
4440
|
const reservationPath = path.join(journalDir, `attempt-0${attempt}.reservation.json`);
|
|
4441
|
+
let reservationSkipReason: string | null = null;
|
|
4131
4442
|
try {
|
|
4132
|
-
await
|
|
4133
|
-
|
|
4134
|
-
|
|
4135
|
-
|
|
4136
|
-
|
|
4443
|
+
await withGjcTeamTaskMutation(taskStore(dir), async () => {
|
|
4444
|
+
const reservationReason = await validateGjcContinuationEligibility(
|
|
4445
|
+
dir,
|
|
4446
|
+
config,
|
|
4447
|
+
worker,
|
|
4448
|
+
task,
|
|
4449
|
+
heartbeat.last_turn_at,
|
|
4450
|
+
staleMs,
|
|
4451
|
+
env,
|
|
4452
|
+
new Date(currentTimeMs() + GJC_TEAM_CONTINUATION_DISPATCH_TIMEOUT_MS + holdMs).toISOString(),
|
|
4453
|
+
);
|
|
4454
|
+
if (reservationReason) {
|
|
4455
|
+
// An ineligible reservation must not abort the monitor pass for every other
|
|
4456
|
+
// worker. Record the same auditable `skipped` outcome the post-dispatch
|
|
4457
|
+
// revalidation site records, so a short or changed lease is journalled
|
|
4458
|
+
// rather than thrown out of `monitorGjcTeam`.
|
|
4459
|
+
reservationSkipReason = reservationReason;
|
|
4460
|
+
const skippedPath = path.join(journalDir, `attempt-0${attempt}.outcome.json`);
|
|
4461
|
+
await createJsonNoClobber(
|
|
4462
|
+
skippedPath,
|
|
4463
|
+
{
|
|
4464
|
+
schema_version: 1,
|
|
4465
|
+
incident_hash: incident,
|
|
4466
|
+
attempt,
|
|
4467
|
+
reservation_sha256: gjcContinuationReservationDigest(reservation),
|
|
4468
|
+
recorded_at: now(),
|
|
4469
|
+
result: "skipped",
|
|
4470
|
+
reason: reservationReason,
|
|
4471
|
+
},
|
|
4472
|
+
stateWriterOptions(skippedPath, "state", "continuation-outcome"),
|
|
4473
|
+
);
|
|
4474
|
+
return;
|
|
4475
|
+
}
|
|
4476
|
+
await createJsonNoClobber(
|
|
4477
|
+
reservationPath,
|
|
4478
|
+
reservation,
|
|
4479
|
+
stateWriterOptions(reservationPath, "state", "continuation-reservation"),
|
|
4480
|
+
);
|
|
4481
|
+
});
|
|
4137
4482
|
} catch (error) {
|
|
4138
4483
|
if (error instanceof AlreadyExistsError) continue;
|
|
4139
4484
|
throw error;
|
|
4140
4485
|
}
|
|
4486
|
+
if (reservationSkipReason !== null) return;
|
|
4141
4487
|
let result: "sent" | "unknown" = "unknown";
|
|
4142
4488
|
let outcomeReason = "tmux_missing_exit_code";
|
|
4143
4489
|
let tmuxExitCode: number | undefined;
|
|
4144
4490
|
let tmuxError: { name: string; code?: string; message: string } | undefined;
|
|
4145
4491
|
let dispatchedAt: string | undefined;
|
|
4146
4492
|
let dispatchHoldUntil: string | undefined;
|
|
4147
|
-
|
|
4148
|
-
|
|
4149
|
-
|
|
4150
|
-
|
|
4151
|
-
|
|
4152
|
-
|
|
4153
|
-
|
|
4154
|
-
|
|
4155
|
-
|
|
4156
|
-
|
|
4157
|
-
|
|
4158
|
-
)
|
|
4159
|
-
|
|
4160
|
-
|
|
4161
|
-
await
|
|
4162
|
-
|
|
4163
|
-
|
|
4164
|
-
|
|
4165
|
-
|
|
4166
|
-
|
|
4167
|
-
|
|
4168
|
-
|
|
4169
|
-
|
|
4170
|
-
|
|
4171
|
-
|
|
4172
|
-
stateWriterOptions(skippedPath, "state", "continuation-outcome"),
|
|
4493
|
+
// The reservation above was written under the task lock, and that lock is
|
|
4494
|
+
// released before dispatch. Revalidation plus send-keys is therefore its own
|
|
4495
|
+
// critical section, and it must exclude concurrent claim mutation: otherwise a
|
|
4496
|
+
// claim-releasing operation (worker GC prune, stale-claim recovery) lands in
|
|
4497
|
+
// the gap, revalidation observes no current claim, and the incident is
|
|
4498
|
+
// journalled `skipped` with no continuation dispatched at all.
|
|
4499
|
+
//
|
|
4500
|
+
// Take the same team mutation fence every public authority-changing operation
|
|
4501
|
+
// takes, and release it before the ACK wait. The ACK is published by a
|
|
4502
|
+
// separate receiver process that must acquire the same cross-process fence,
|
|
4503
|
+
// so holding it across the wait would deadlock the very ACK being awaited.
|
|
4504
|
+
const attemptOutcome = await withGjcTeamMutationFence(dir, async () => {
|
|
4505
|
+
if (gjcTeamRuntimeTestSeams?.continuationBeforeDispatch)
|
|
4506
|
+
await gjcTeamRuntimeTestSeams.continuationBeforeDispatch();
|
|
4507
|
+
const revalidationReason = await validateGjcContinuationEligibility(
|
|
4508
|
+
dir,
|
|
4509
|
+
config,
|
|
4510
|
+
worker,
|
|
4511
|
+
task,
|
|
4512
|
+
heartbeat.last_turn_at,
|
|
4513
|
+
staleMs,
|
|
4514
|
+
env,
|
|
4515
|
+
new Date(currentTimeMs() + GJC_TEAM_CONTINUATION_DISPATCH_TIMEOUT_MS + holdMs).toISOString(),
|
|
4516
|
+
|
|
4517
|
+
true,
|
|
4173
4518
|
);
|
|
4519
|
+
if (revalidationReason) return { kind: "skipped" as const, reason: revalidationReason };
|
|
4520
|
+
// Eligibility validation above proves the worker owns a real pane, but that
|
|
4521
|
+
// runtime proof does not narrow the optional `pane_id` field for the type
|
|
4522
|
+
// checker. Bind it to a proven non-empty local and dispatch through that, so
|
|
4523
|
+
// the frozen argv is `readonly string[]` and both send operations address the
|
|
4524
|
+
// identical pane. A missing pane id here is an eligibility gap, not a
|
|
4525
|
+
// dispatch error, so report it as the same typed skipped outcome.
|
|
4526
|
+
const paneId = worker.pane_id;
|
|
4527
|
+
if (!paneId) return { kind: "skipped" as const, reason: "worker_pane_missing" };
|
|
4528
|
+
try {
|
|
4529
|
+
const args: readonly string[] = Object.freeze([
|
|
4530
|
+
"send-keys",
|
|
4531
|
+
"-l",
|
|
4532
|
+
"-t",
|
|
4533
|
+
paneId,
|
|
4534
|
+
continuationPrompt,
|
|
4535
|
+
";",
|
|
4536
|
+
"send-keys",
|
|
4537
|
+
"-t",
|
|
4538
|
+
paneId,
|
|
4539
|
+
"Enter",
|
|
4540
|
+
]);
|
|
4541
|
+
const dispatch = await (gjcTeamRuntimeTestSeams?.continuationTmuxDispatch
|
|
4542
|
+
? gjcTeamRuntimeTestSeams.continuationTmuxDispatch(config.tmux_command, args)
|
|
4543
|
+
: (() => {
|
|
4544
|
+
executeTeamTmuxMutation(config, {
|
|
4545
|
+
type: "literal-send",
|
|
4546
|
+
paneId,
|
|
4547
|
+
text: continuationPrompt,
|
|
4548
|
+
deferredProof: "continuation-outcome",
|
|
4549
|
+
});
|
|
4550
|
+
return executeTeamTmuxMutation(config, {
|
|
4551
|
+
type: "key-send",
|
|
4552
|
+
paneId,
|
|
4553
|
+
key: "Enter",
|
|
4554
|
+
deferredProof: "continuation-outcome",
|
|
4555
|
+
});
|
|
4556
|
+
})());
|
|
4557
|
+
return { kind: "dispatched" as const, exitCode: dispatch.exitCode };
|
|
4558
|
+
} catch (error) {
|
|
4559
|
+
return { kind: "threw" as const, error };
|
|
4560
|
+
}
|
|
4561
|
+
});
|
|
4562
|
+
if (attemptOutcome.kind === "skipped") {
|
|
4563
|
+
const skippedPath = path.join(journalDir, `attempt-0${attempt}.outcome.json`);
|
|
4564
|
+
await withGjcTeamTaskMutation(taskStore(dir), async () => {
|
|
4565
|
+
await createJsonNoClobber(
|
|
4566
|
+
skippedPath,
|
|
4567
|
+
{
|
|
4568
|
+
schema_version: 1,
|
|
4569
|
+
incident_hash: incident,
|
|
4570
|
+
attempt,
|
|
4571
|
+
reservation_sha256: gjcContinuationReservationDigest(reservation),
|
|
4572
|
+
recorded_at: now(),
|
|
4573
|
+
result: "skipped",
|
|
4574
|
+
reason: attemptOutcome.reason,
|
|
4575
|
+
},
|
|
4576
|
+
stateWriterOptions(skippedPath, "state", "continuation-outcome"),
|
|
4577
|
+
);
|
|
4578
|
+
});
|
|
4174
4579
|
return;
|
|
4175
4580
|
}
|
|
4176
4581
|
try {
|
|
4177
|
-
|
|
4178
|
-
|
|
4179
|
-
|
|
4180
|
-
|
|
4181
|
-
|
|
4182
|
-
|
|
4183
|
-
|
|
4184
|
-
|
|
4185
|
-
|
|
4186
|
-
|
|
4187
|
-
|
|
4188
|
-
|
|
4189
|
-
|
|
4190
|
-
|
|
4191
|
-
|
|
4192
|
-
|
|
4193
|
-
|
|
4194
|
-
|
|
4195
|
-
|
|
4196
|
-
|
|
4197
|
-
|
|
4198
|
-
|
|
4199
|
-
|
|
4200
|
-
|
|
4201
|
-
|
|
4202
|
-
|
|
4203
|
-
|
|
4204
|
-
|
|
4205
|
-
|
|
4582
|
+
if (attemptOutcome.kind === "threw") throw attemptOutcome.error;
|
|
4583
|
+
tmuxExitCode = attemptOutcome.exitCode;
|
|
4584
|
+
if (attemptOutcome.exitCode === 0) {
|
|
4585
|
+
// Bound the ack wait on the seamed clock so a test that advances `nowMs`
|
|
4586
|
+
// exhausts the dispatch budget deterministically instead of sleeping.
|
|
4587
|
+
// The wait loop advances EITHER the seamed clock (a test-supplied
|
|
4588
|
+
// continuationAckPoll) OR wall time (the real Bun.sleep below). Bounding
|
|
4589
|
+
// only one lets the other run forever: with a frozen fake clock and no ack
|
|
4590
|
+
// seam, a seamed-only deadline never trips. Bound both.
|
|
4591
|
+
const ackDeadline = currentTimeMs() + GJC_TEAM_CONTINUATION_DISPATCH_TIMEOUT_MS;
|
|
4592
|
+
const ackWallDeadline = Date.now() + GJC_TEAM_CONTINUATION_DISPATCH_TIMEOUT_MS;
|
|
4593
|
+
while (true) {
|
|
4594
|
+
const ackAuthorityReason = await validateGjcContinuationAckAuthority(
|
|
4595
|
+
dir,
|
|
4596
|
+
config,
|
|
4597
|
+
worker,
|
|
4598
|
+
task,
|
|
4599
|
+
heartbeat.last_turn_at,
|
|
4600
|
+
staleMs,
|
|
4601
|
+
env,
|
|
4602
|
+
reservation,
|
|
4603
|
+
incident,
|
|
4604
|
+
attempt,
|
|
4605
|
+
);
|
|
4606
|
+
if (ackAuthorityReason) {
|
|
4607
|
+
outcomeReason = `continuation_${ackAuthorityReason}`;
|
|
4608
|
+
break;
|
|
4609
|
+
}
|
|
4610
|
+
const ack = await readContinuationJson<Record<string, unknown>>(
|
|
4611
|
+
path.join(journalDir, `attempt-0${attempt}.ack.json`),
|
|
4612
|
+
);
|
|
4613
|
+
if (isValidGjcContinuationAck(ack, reservation, incident, attempt)) {
|
|
4614
|
+
result = "sent";
|
|
4615
|
+
outcomeReason = "tmux_sent";
|
|
4616
|
+
dispatchedAt = now();
|
|
4617
|
+
dispatchHoldUntil = new Date(
|
|
4618
|
+
Date.parse(dispatchedAt) + (attempt === 1 ? 30_000 : 120_000),
|
|
4619
|
+
).toISOString();
|
|
4620
|
+
break;
|
|
4621
|
+
}
|
|
4622
|
+
if (currentTimeMs() >= ackDeadline || Date.now() >= ackWallDeadline) {
|
|
4623
|
+
outcomeReason = "tmux_exit_zero_unacknowledged";
|
|
4624
|
+
break;
|
|
4625
|
+
}
|
|
4626
|
+
if (gjcTeamRuntimeTestSeams?.continuationAckPoll) {
|
|
4627
|
+
await gjcTeamRuntimeTestSeams.continuationAckPoll();
|
|
4628
|
+
} else {
|
|
4629
|
+
await Bun.sleep(GJC_TEAM_CONTINUATION_ACK_POLL_MS);
|
|
4630
|
+
}
|
|
4631
|
+
}
|
|
4632
|
+
} else if (typeof attemptOutcome.exitCode === "number") outcomeReason = "tmux_nonzero_exit";
|
|
4206
4633
|
} catch (error) {
|
|
4207
4634
|
outcomeReason = "tmux_dispatch_threw";
|
|
4208
4635
|
const name = normalizeGjcContinuationDispatchError(
|
|
@@ -4226,31 +4653,58 @@ async function continueStalledGjcTeamWorkers(
|
|
|
4226
4653
|
};
|
|
4227
4654
|
}
|
|
4228
4655
|
const outcomePath = path.join(journalDir, `attempt-0${attempt}.outcome.json`);
|
|
4229
|
-
|
|
4230
|
-
|
|
4231
|
-
|
|
4232
|
-
|
|
4233
|
-
|
|
4234
|
-
|
|
4656
|
+
await withGjcTeamTaskMutation(taskStore(dir), async () => {
|
|
4657
|
+
if (result === "sent") {
|
|
4658
|
+
const outcomeAuthorityReason = await validateGjcContinuationAckAuthority(
|
|
4659
|
+
dir,
|
|
4660
|
+
config,
|
|
4661
|
+
worker,
|
|
4662
|
+
task,
|
|
4663
|
+
heartbeat.last_turn_at,
|
|
4664
|
+
staleMs,
|
|
4665
|
+
env,
|
|
4666
|
+
reservation,
|
|
4667
|
+
incident,
|
|
4235
4668
|
attempt,
|
|
4236
|
-
|
|
4237
|
-
|
|
4238
|
-
|
|
4239
|
-
|
|
4240
|
-
|
|
4241
|
-
|
|
4242
|
-
|
|
4243
|
-
|
|
4244
|
-
|
|
4245
|
-
|
|
4246
|
-
|
|
4247
|
-
|
|
4248
|
-
|
|
4249
|
-
|
|
4250
|
-
|
|
4251
|
-
|
|
4252
|
-
|
|
4253
|
-
|
|
4669
|
+
);
|
|
4670
|
+
const ack = await readContinuationJson<Record<string, unknown>>(
|
|
4671
|
+
path.join(journalDir, `attempt-0${attempt}.ack.json`),
|
|
4672
|
+
);
|
|
4673
|
+
if (outcomeAuthorityReason || !isValidGjcContinuationAck(ack, reservation, incident, attempt)) {
|
|
4674
|
+
result = "unknown";
|
|
4675
|
+
outcomeReason = outcomeAuthorityReason
|
|
4676
|
+
? `continuation_${outcomeAuthorityReason}`
|
|
4677
|
+
: "tmux_exit_zero_unacknowledged";
|
|
4678
|
+
dispatchedAt = undefined;
|
|
4679
|
+
dispatchHoldUntil = undefined;
|
|
4680
|
+
}
|
|
4681
|
+
}
|
|
4682
|
+
try {
|
|
4683
|
+
await createJsonNoClobber(
|
|
4684
|
+
outcomePath,
|
|
4685
|
+
{
|
|
4686
|
+
schema_version: 1,
|
|
4687
|
+
incident_hash: incident,
|
|
4688
|
+
attempt,
|
|
4689
|
+
reservation_sha256: gjcContinuationReservationDigest(reservation),
|
|
4690
|
+
recorded_at: now(),
|
|
4691
|
+
result,
|
|
4692
|
+
reason: outcomeReason,
|
|
4693
|
+
...(tmuxExitCode === undefined ? {} : { tmux_exit_code: tmuxExitCode }),
|
|
4694
|
+
...(result === "sent" && dispatchedAt && dispatchHoldUntil
|
|
4695
|
+
? { dispatched_at: dispatchedAt, hold_until: dispatchHoldUntil }
|
|
4696
|
+
: {}),
|
|
4697
|
+
...(tmuxError ? { tmux_error: tmuxError } : {}),
|
|
4698
|
+
},
|
|
4699
|
+
stateWriterOptions(outcomePath, "state", "continuation-outcome"),
|
|
4700
|
+
);
|
|
4701
|
+
} catch (error) {
|
|
4702
|
+
if (!(error instanceof AlreadyExistsError)) throw error;
|
|
4703
|
+
const existing = await readContinuationJson<Record<string, unknown>>(outcomePath);
|
|
4704
|
+
if (!isValidGjcContinuationOutcome(existing, reservation, incident, attempt))
|
|
4705
|
+
throw new Error(`invalid_continuation_outcome:${incident}:${attempt}`);
|
|
4706
|
+
}
|
|
4707
|
+
});
|
|
4254
4708
|
return;
|
|
4255
4709
|
}
|
|
4256
4710
|
}
|
|
@@ -4263,9 +4717,9 @@ export async function monitorGjcTeam(
|
|
|
4263
4717
|
const dir = await findTeamDir(teamName, cwd, env);
|
|
4264
4718
|
const config = await readConfig(dir);
|
|
4265
4719
|
const previous = await readJsonFile<GjcTeamMonitorSnapshot>(monitorSnapshotPath(dir));
|
|
4720
|
+
await continueStalledGjcTeamWorkers(dir, config, env);
|
|
4266
4721
|
await withGjcTeamTaskMutation(taskStore(dir), async capability => {
|
|
4267
4722
|
const config = await readConfig(dir);
|
|
4268
|
-
await continueStalledGjcTeamWorkers(dir, config, env);
|
|
4269
4723
|
await reconcileGjcTeamStaleClaimsUnlocked(workerOrchestrationRuntime, teamName, dir, config, env, capability);
|
|
4270
4724
|
await computeLifecycleNudges(config, dir, cwd, env);
|
|
4271
4725
|
});
|
|
@@ -4309,6 +4763,15 @@ async function writeGjcWorkerStartupAck(
|
|
|
4309
4763
|
): Promise<Record<string, unknown>> {
|
|
4310
4764
|
return writeWorkerStartupAck(workerRuntime, teamName, worker, cwd, env, input);
|
|
4311
4765
|
}
|
|
4766
|
+
async function writeGjcWorkerContinuationAck(
|
|
4767
|
+
teamName: string,
|
|
4768
|
+
worker: string,
|
|
4769
|
+
cwd: string,
|
|
4770
|
+
env: NodeJS.ProcessEnv,
|
|
4771
|
+
input: Record<string, unknown>,
|
|
4772
|
+
): Promise<Record<string, unknown>> {
|
|
4773
|
+
return writeWorkerContinuationAck(workerRuntime, teamName, worker, cwd, env, input);
|
|
4774
|
+
}
|
|
4312
4775
|
function parseDurationEnv(env: NodeJS.ProcessEnv, name: string, fallbackMs: number): number {
|
|
4313
4776
|
const raw = env[name]?.trim();
|
|
4314
4777
|
if (!raw) return fallbackMs;
|
|
@@ -5183,6 +5646,8 @@ export async function executeGjcTeamApiOperation(
|
|
|
5183
5646
|
}
|
|
5184
5647
|
case "worker-startup-ack":
|
|
5185
5648
|
return writeGjcWorkerStartupAck(teamName, worker, cwd, env, input);
|
|
5649
|
+
case "worker-continuation-ack":
|
|
5650
|
+
return writeGjcWorkerContinuationAck(teamName, worker, cwd, env, input);
|
|
5186
5651
|
case "read-config":
|
|
5187
5652
|
return await readConfig(await findTeamDir(teamName, cwd, env));
|
|
5188
5653
|
case "read-manifest":
|