@gajae-code/coding-agent 0.2.5 → 0.3.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 +28 -0
- package/dist/types/async/job-manager.d.ts +91 -2
- package/dist/types/cli/args.d.ts +1 -1
- package/dist/types/commands/deep-interview.d.ts +3 -0
- package/dist/types/commands/harness.d.ts +37 -0
- package/dist/types/config/keybindings.d.ts +5 -0
- package/dist/types/config/settings-schema.d.ts +10 -4
- package/dist/types/config/settings.d.ts +2 -0
- package/dist/types/debug/crash-diagnostics.d.ts +45 -0
- package/dist/types/debug/runtime-gauges.d.ts +6 -0
- package/dist/types/deep-interview/render-middleware.d.ts +6 -0
- package/dist/types/eval/py/executor.d.ts +2 -0
- package/dist/types/eval/py/kernel.d.ts +2 -0
- package/dist/types/exec/bash-executor.d.ts +10 -0
- package/dist/types/extensibility/custom-tools/types.d.ts +1 -0
- package/dist/types/extensibility/extensions/types.d.ts +6 -0
- package/dist/types/extensibility/shared-events.d.ts +1 -0
- package/dist/types/gjc-runtime/cli-write-receipt.d.ts +24 -0
- package/dist/types/gjc-runtime/deep-interview-runtime.d.ts +1 -0
- package/dist/types/gjc-runtime/state-graph.d.ts +4 -0
- package/dist/types/gjc-runtime/state-migrations.d.ts +33 -0
- package/dist/types/gjc-runtime/state-renderer.d.ts +65 -0
- package/dist/types/gjc-runtime/state-runtime.d.ts +2 -0
- package/dist/types/gjc-runtime/state-schema.d.ts +317 -0
- package/dist/types/gjc-runtime/state-validation.d.ts +6 -0
- package/dist/types/gjc-runtime/state-writer.d.ts +147 -0
- package/dist/types/gjc-runtime/team-runtime.d.ts +81 -7
- package/dist/types/gjc-runtime/workflow-command-ref.d.ts +43 -0
- package/dist/types/gjc-runtime/workflow-manifest.d.ts +54 -0
- package/dist/types/harness-control-plane/classifier.d.ts +13 -0
- package/dist/types/harness-control-plane/control-endpoint.d.ts +31 -0
- package/dist/types/harness-control-plane/finalize.d.ts +47 -0
- package/dist/types/harness-control-plane/frame-mapper.d.ts +29 -0
- package/dist/types/harness-control-plane/operate.d.ts +35 -0
- package/dist/types/harness-control-plane/owner.d.ts +46 -0
- package/dist/types/harness-control-plane/preserve.d.ts +19 -0
- package/dist/types/harness-control-plane/receipts.d.ts +88 -0
- package/dist/types/harness-control-plane/rpc-adapter.d.ts +66 -0
- package/dist/types/harness-control-plane/seams.d.ts +21 -0
- package/dist/types/harness-control-plane/session-lease.d.ts +65 -0
- package/dist/types/harness-control-plane/state-machine.d.ts +19 -0
- package/dist/types/harness-control-plane/storage.d.ts +53 -0
- package/dist/types/harness-control-plane/types.d.ts +162 -0
- package/dist/types/hooks/skill-keywords.d.ts +2 -1
- package/dist/types/hooks/skill-state.d.ts +23 -29
- package/dist/types/internal-urls/agent-protocol.d.ts +2 -2
- package/dist/types/internal-urls/artifact-protocol.d.ts +2 -2
- package/dist/types/internal-urls/registry-helpers.d.ts +8 -7
- package/dist/types/internal-urls/types.d.ts +4 -0
- package/dist/types/lsp/index.d.ts +10 -10
- package/dist/types/modes/bridge/auth.d.ts +12 -0
- package/dist/types/modes/bridge/bridge-client-bridge.d.ts +9 -0
- package/dist/types/modes/bridge/bridge-mode.d.ts +44 -0
- package/dist/types/modes/bridge/bridge-ui-context.d.ts +88 -0
- package/dist/types/modes/bridge/event-stream.d.ts +8 -0
- package/dist/types/modes/components/custom-editor.d.ts +6 -0
- package/dist/types/modes/components/hook-selector.d.ts +1 -0
- package/dist/types/modes/components/jobs-overlay-model.d.ts +31 -0
- package/dist/types/modes/components/jobs-overlay.d.ts +30 -0
- package/dist/types/modes/components/status-line/types.d.ts +2 -0
- package/dist/types/modes/components/status-line.d.ts +2 -0
- package/dist/types/modes/controllers/input-controller.d.ts +1 -0
- package/dist/types/modes/controllers/selector-controller.d.ts +8 -0
- package/dist/types/modes/index.d.ts +1 -0
- package/dist/types/modes/interactive-mode.d.ts +2 -0
- package/dist/types/modes/jobs-observer.d.ts +57 -0
- package/dist/types/modes/rpc/host-tools.d.ts +1 -16
- package/dist/types/modes/rpc/host-uris.d.ts +1 -38
- package/dist/types/modes/shared/agent-wire/command-dispatch.d.ts +20 -0
- package/dist/types/modes/shared/agent-wire/command-validation.d.ts +2 -0
- package/dist/types/modes/shared/agent-wire/event-envelope.d.ts +24 -0
- package/dist/types/modes/shared/agent-wire/handshake.d.ts +46 -0
- package/dist/types/modes/shared/agent-wire/host-tool-bridge.d.ts +16 -0
- package/dist/types/modes/shared/agent-wire/host-uri-bridge.d.ts +17 -0
- package/dist/types/modes/shared/agent-wire/protocol.d.ts +44 -0
- package/dist/types/modes/shared/agent-wire/responses.d.ts +4 -0
- package/dist/types/modes/shared/agent-wire/scopes.d.ts +18 -0
- package/dist/types/modes/shared/agent-wire/ui-request-broker.d.ts +42 -0
- package/dist/types/modes/shared/agent-wire/ui-result.d.ts +27 -0
- package/dist/types/modes/types.d.ts +2 -0
- package/dist/types/sdk.d.ts +4 -0
- package/dist/types/session/agent-session.d.ts +19 -1
- package/dist/types/skill-state/active-state.d.ts +2 -0
- package/dist/types/skill-state/deep-interview-mutation-guard.d.ts +1 -1
- package/dist/types/skill-state/workflow-state-contract.d.ts +25 -2
- package/dist/types/skill-state/workflow-state-version.d.ts +3 -0
- package/dist/types/task/executor.d.ts +3 -0
- package/dist/types/task/id.d.ts +7 -0
- package/dist/types/task/index.d.ts +5 -0
- package/dist/types/task/receipt.d.ts +85 -0
- package/dist/types/task/spawn-gate.d.ts +38 -0
- package/dist/types/task/types.d.ts +198 -14
- package/dist/types/tools/cron.d.ts +6 -0
- package/dist/types/tools/index.d.ts +2 -0
- package/dist/types/tools/path-utils.d.ts +1 -0
- package/dist/types/tools/subagent.d.ts +26 -1
- package/package.json +7 -7
- package/scripts/build-binary.ts +7 -0
- package/src/async/job-manager.ts +334 -6
- package/src/cli/args.ts +9 -2
- package/src/cli/auth-broker-cli.ts +1 -0
- package/src/cli/config-cli.ts +10 -2
- package/src/cli.ts +2 -0
- package/src/commands/deep-interview.ts +1 -0
- package/src/commands/harness.ts +862 -0
- package/src/commands/launch.ts +2 -2
- package/src/commands/state.ts +2 -1
- package/src/commands/team.ts +54 -39
- package/src/config/keybindings.ts +6 -0
- package/src/config/settings-schema.ts +13 -3
- package/src/config/settings.ts +5 -0
- package/src/dap/client.ts +17 -3
- package/src/debug/crash-diagnostics.ts +223 -0
- package/src/debug/runtime-gauges.ts +20 -0
- package/src/deep-interview/render-middleware.ts +372 -0
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +1 -1
- package/src/defaults/gjc/skills/ralplan/SKILL.md +31 -2
- package/src/defaults/gjc/skills/team/SKILL.md +47 -21
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +106 -13
- package/src/eval/py/executor.ts +21 -1
- package/src/eval/py/kernel.ts +15 -0
- package/src/exec/bash-executor.ts +41 -0
- package/src/extensibility/custom-tools/types.ts +1 -0
- package/src/extensibility/extensions/types.ts +6 -0
- package/src/extensibility/shared-events.ts +1 -0
- package/src/gjc-runtime/cli-write-receipt.ts +31 -0
- package/src/gjc-runtime/deep-interview-runtime.ts +98 -42
- package/src/gjc-runtime/goal-mode-request.ts +11 -3
- package/src/gjc-runtime/ralplan-runtime.ts +235 -43
- package/src/gjc-runtime/state-graph.ts +86 -0
- package/src/gjc-runtime/state-migrations.ts +179 -0
- package/src/gjc-runtime/state-renderer.ts +345 -0
- package/src/gjc-runtime/state-runtime.ts +1155 -46
- package/src/gjc-runtime/state-schema.ts +192 -0
- package/src/gjc-runtime/state-validation.ts +49 -0
- package/src/gjc-runtime/state-writer.ts +749 -0
- package/src/gjc-runtime/team-runtime.ts +1255 -189
- package/src/gjc-runtime/ultragoal-runtime.ts +460 -43
- package/src/gjc-runtime/workflow-command-ref.ts +239 -0
- package/src/gjc-runtime/workflow-manifest.generated.json +1601 -0
- package/src/gjc-runtime/workflow-manifest.ts +427 -0
- package/src/harness-control-plane/classifier.ts +128 -0
- package/src/harness-control-plane/control-endpoint.ts +148 -0
- package/src/harness-control-plane/finalize.ts +222 -0
- package/src/harness-control-plane/frame-mapper.ts +286 -0
- package/src/harness-control-plane/operate.ts +225 -0
- package/src/harness-control-plane/owner.ts +600 -0
- package/src/harness-control-plane/preserve.ts +102 -0
- package/src/harness-control-plane/receipts.ts +216 -0
- package/src/harness-control-plane/rpc-adapter.ts +276 -0
- package/src/harness-control-plane/seams.ts +39 -0
- package/src/harness-control-plane/session-lease.ts +388 -0
- package/src/harness-control-plane/state-machine.ts +98 -0
- package/src/harness-control-plane/storage.ts +257 -0
- package/src/harness-control-plane/types.ts +214 -0
- package/src/hooks/skill-keywords.ts +4 -2
- package/src/hooks/skill-state.ts +197 -64
- package/src/internal-urls/agent-protocol.ts +68 -21
- package/src/internal-urls/artifact-protocol.ts +12 -17
- package/src/internal-urls/docs-index.generated.ts +3 -2
- package/src/internal-urls/registry-helpers.ts +19 -16
- package/src/internal-urls/types.ts +4 -0
- package/src/lsp/client.ts +18 -2
- package/src/main.ts +21 -5
- package/src/modes/bridge/auth.ts +41 -0
- package/src/modes/bridge/bridge-client-bridge.ts +47 -0
- package/src/modes/bridge/bridge-mode.ts +520 -0
- package/src/modes/bridge/bridge-ui-context.ts +200 -0
- package/src/modes/bridge/event-stream.ts +70 -0
- package/src/modes/components/assistant-message.ts +5 -1
- package/src/modes/components/custom-editor.ts +101 -0
- package/src/modes/components/hook-selector.ts +133 -20
- package/src/modes/components/jobs-overlay-model.ts +109 -0
- package/src/modes/components/jobs-overlay.ts +172 -0
- package/src/modes/components/status-line/presets.ts +7 -5
- package/src/modes/components/status-line/segments.ts +25 -0
- package/src/modes/components/status-line/types.ts +2 -0
- package/src/modes/components/status-line.ts +9 -1
- package/src/modes/controllers/event-controller.ts +71 -6
- package/src/modes/controllers/extension-ui-controller.ts +43 -1
- package/src/modes/controllers/input-controller.ts +105 -9
- package/src/modes/controllers/selector-controller.ts +31 -1
- package/src/modes/index.ts +1 -0
- package/src/modes/interactive-mode.ts +28 -0
- package/src/modes/jobs-observer.ts +204 -0
- package/src/modes/rpc/host-tools.ts +1 -186
- package/src/modes/rpc/host-uris.ts +1 -235
- package/src/modes/rpc/rpc-client.ts +25 -10
- package/src/modes/rpc/rpc-mode.ts +12 -381
- package/src/modes/shared/agent-wire/command-dispatch.ts +341 -0
- package/src/modes/shared/agent-wire/command-validation.ts +131 -0
- package/src/modes/shared/agent-wire/event-envelope.ts +108 -0
- package/src/modes/shared/agent-wire/handshake.ts +117 -0
- package/src/modes/shared/agent-wire/host-tool-bridge.ts +194 -0
- package/src/modes/shared/agent-wire/host-uri-bridge.ts +236 -0
- package/src/modes/shared/agent-wire/protocol.ts +96 -0
- package/src/modes/shared/agent-wire/responses.ts +17 -0
- package/src/modes/shared/agent-wire/scopes.ts +89 -0
- package/src/modes/shared/agent-wire/ui-request-broker.ts +150 -0
- package/src/modes/shared/agent-wire/ui-result.ts +48 -0
- package/src/modes/types.ts +2 -0
- package/src/prompts/agents/executor.md +13 -0
- package/src/prompts/tools/subagent.md +39 -4
- package/src/prompts/tools/task-summary.md +3 -9
- package/src/prompts/tools/task.md +5 -1
- package/src/sdk.ts +8 -0
- package/src/session/agent-session.ts +445 -71
- package/src/session/session-manager.ts +13 -1
- package/src/skill-state/active-state.ts +58 -65
- package/src/skill-state/deep-interview-mutation-guard.ts +114 -17
- package/src/skill-state/initial-phase.ts +2 -0
- package/src/skill-state/workflow-state-contract.ts +33 -4
- package/src/skill-state/workflow-state-version.ts +3 -0
- package/src/slash-commands/builtin-registry.ts +8 -0
- package/src/task/executor.ts +79 -13
- package/src/task/id.ts +33 -0
- package/src/task/index.ts +376 -74
- package/src/task/output-manager.ts +5 -4
- package/src/task/receipt.ts +297 -0
- package/src/task/render.ts +54 -134
- package/src/task/spawn-gate.ts +132 -0
- package/src/task/types.ts +104 -10
- package/src/tools/ask.ts +88 -27
- package/src/tools/ast-edit.ts +1 -0
- package/src/tools/ast-grep.ts +1 -0
- package/src/tools/bash.ts +1 -1
- package/src/tools/cron.ts +48 -0
- package/src/tools/find.ts +4 -1
- package/src/tools/index.ts +2 -0
- package/src/tools/path-utils.ts +3 -2
- package/src/tools/read.ts +1 -0
- package/src/tools/search.ts +1 -0
- package/src/tools/skill.ts +6 -1
- package/src/tools/subagent.ts +423 -79
|
@@ -0,0 +1,862 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `gjc harness <verb>` — AI-native stateless JSON CLI for the coding-harness
|
|
3
|
+
* operations control plane (v1, gajae-code adapter).
|
|
4
|
+
*
|
|
5
|
+
* Every verb emits the universal contract `{ ok, state, evidence, nextAllowedActions }`.
|
|
6
|
+
* Foundation milestone (M1/M2) implements: start, observe, classify, events, retire,
|
|
7
|
+
* and the spec-required `owner-not-live` blocking for submit. Owner-runtime verbs
|
|
8
|
+
* (recover/validate/finalize/operate) return an honest `pending-<milestone>` contract
|
|
9
|
+
* until the RuntimeOwner (M3+) lands.
|
|
10
|
+
*/
|
|
11
|
+
import { execFileSync } from "node:child_process";
|
|
12
|
+
import { existsSync } from "node:fs";
|
|
13
|
+
import { Args, Command, Flags } from "@gajae-code/utils/cli";
|
|
14
|
+
import { resolveGjcTmuxCommand, sanitizeTmuxToken } from "../gjc-runtime/tmux-common";
|
|
15
|
+
import { classifyRecovery } from "../harness-control-plane/classifier";
|
|
16
|
+
import { callEndpoint, EndpointUnreachableError } from "../harness-control-plane/control-endpoint";
|
|
17
|
+
import { RuntimeOwner, resolveOwner } from "../harness-control-plane/owner";
|
|
18
|
+
import { GajaeCodeRpc } from "../harness-control-plane/rpc-adapter";
|
|
19
|
+
import { buildResponse, buildStateView } from "../harness-control-plane/state-machine";
|
|
20
|
+
import {
|
|
21
|
+
generateSessionId,
|
|
22
|
+
readEvents,
|
|
23
|
+
readSessionState,
|
|
24
|
+
resolveHarnessRoot,
|
|
25
|
+
sessionPaths,
|
|
26
|
+
writeSessionState,
|
|
27
|
+
} from "../harness-control-plane/storage";
|
|
28
|
+
import {
|
|
29
|
+
DEFAULT_RETRY_BUDGET,
|
|
30
|
+
type EventEnvelope,
|
|
31
|
+
type GitDelta,
|
|
32
|
+
type Harness as HarnessKind,
|
|
33
|
+
type Observation,
|
|
34
|
+
type RetryBudget,
|
|
35
|
+
SESSION_SCHEMA_VERSION,
|
|
36
|
+
type SessionHandle,
|
|
37
|
+
type SessionState,
|
|
38
|
+
} from "../harness-control-plane/types";
|
|
39
|
+
|
|
40
|
+
function writeJson(value: unknown): void {
|
|
41
|
+
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function nowIso(): string {
|
|
45
|
+
return new Date().toISOString();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function parseInput(raw: string | undefined): Record<string, unknown> {
|
|
49
|
+
if (!raw?.trim()) return {};
|
|
50
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
51
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
52
|
+
throw new Error("input_must_be_json_object");
|
|
53
|
+
}
|
|
54
|
+
return parsed as Record<string, unknown>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function gitDeltaFor(workspace: string): { gitDelta: GitDelta; branch: string | null; deleted: boolean } {
|
|
58
|
+
if (!existsSync(workspace)) return { gitDelta: "unknown", branch: null, deleted: true };
|
|
59
|
+
let branch: string | null = null;
|
|
60
|
+
try {
|
|
61
|
+
branch = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
|
|
62
|
+
cwd: workspace,
|
|
63
|
+
encoding: "utf8",
|
|
64
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
65
|
+
}).trim();
|
|
66
|
+
} catch {
|
|
67
|
+
branch = null;
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
const porcelain = execFileSync("git", ["status", "--porcelain"], {
|
|
71
|
+
cwd: workspace,
|
|
72
|
+
encoding: "utf8",
|
|
73
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
74
|
+
});
|
|
75
|
+
return { gitDelta: porcelain.trim().length > 0 ? "dirty" : "clean", branch, deleted: false };
|
|
76
|
+
} catch {
|
|
77
|
+
return { gitDelta: "unknown", branch, deleted: false };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
interface HarnessPreflight {
|
|
81
|
+
ok: boolean;
|
|
82
|
+
blockers: string[];
|
|
83
|
+
workspace: string;
|
|
84
|
+
actualBranch: string | null;
|
|
85
|
+
declaredBranch: string | null;
|
|
86
|
+
normalizedIssueOrPr: string | null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function normalizeIssueOrPr(value: unknown): string | null {
|
|
90
|
+
if (value === undefined || value === null) return null;
|
|
91
|
+
if (typeof value === "number") {
|
|
92
|
+
if (Number.isSafeInteger(value) && value > 0) return String(value);
|
|
93
|
+
throw new Error(`invalid_issue_or_pr:${value}`);
|
|
94
|
+
}
|
|
95
|
+
if (typeof value !== "string") throw new Error("invalid_issue_or_pr:not-string-or-number");
|
|
96
|
+
const trimmed = value.trim();
|
|
97
|
+
if (!trimmed) return null;
|
|
98
|
+
const patterns = [
|
|
99
|
+
/^#?(\d+)$/i,
|
|
100
|
+
/^(?:pr|pull|issue)[-_#]?(\d+)$/i,
|
|
101
|
+
/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+#(\d+)$/,
|
|
102
|
+
/^(?:https?:\/\/github\.com\/)?[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/(?:pull|issues)\/(\d+)\/?$/i,
|
|
103
|
+
];
|
|
104
|
+
for (const pattern of patterns) {
|
|
105
|
+
const match = trimmed.match(pattern);
|
|
106
|
+
if (match?.[1]) return match[1];
|
|
107
|
+
}
|
|
108
|
+
throw new Error(`invalid_issue_or_pr:${trimmed}`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function gitOutput(workspace: string, args: string[]): string | null {
|
|
112
|
+
try {
|
|
113
|
+
return execFileSync("git", args, {
|
|
114
|
+
cwd: workspace,
|
|
115
|
+
encoding: "utf8",
|
|
116
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
117
|
+
}).trim();
|
|
118
|
+
} catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function buildPreflight(input: Record<string, unknown>): HarnessPreflight {
|
|
124
|
+
const workspace = typeof input.workspace === "string" ? input.workspace : process.cwd();
|
|
125
|
+
const declaredBranch = typeof input.branch === "string" && input.branch.trim() ? input.branch.trim() : null;
|
|
126
|
+
const blockers: string[] = [];
|
|
127
|
+
const gitRoot = gitOutput(workspace, ["rev-parse", "--show-toplevel"]);
|
|
128
|
+
const actualBranch = gitRoot ? gitOutput(workspace, ["rev-parse", "--abbrev-ref", "HEAD"]) : null;
|
|
129
|
+
let normalizedIssueOrPr: string | null = null;
|
|
130
|
+
|
|
131
|
+
if (!gitRoot) blockers.push("workspace-not-git-repo");
|
|
132
|
+
if (gitRoot && actualBranch === "HEAD") blockers.push("detached-head");
|
|
133
|
+
if (declaredBranch && actualBranch && actualBranch !== "HEAD" && declaredBranch !== actualBranch) {
|
|
134
|
+
blockers.push("branch-mismatch");
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
normalizedIssueOrPr = normalizeIssueOrPr(input.issueOrPr ?? input.pr ?? input.issue);
|
|
138
|
+
} catch (error) {
|
|
139
|
+
blockers.push(error instanceof Error ? error.message : String(error));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
ok: blockers.length === 0,
|
|
144
|
+
blockers,
|
|
145
|
+
workspace,
|
|
146
|
+
actualBranch: actualBranch === "HEAD" ? null : actualBranch,
|
|
147
|
+
declaredBranch,
|
|
148
|
+
normalizedIssueOrPr,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function startFatalPreflightBlockers(input: Record<string, unknown>, preflight: HarnessPreflight): string[] {
|
|
153
|
+
const strict = input.strictPreflight === true || typeof input.branch === "string";
|
|
154
|
+
return preflight.blockers.filter(blocker => {
|
|
155
|
+
if (blocker === "branch-mismatch") return true;
|
|
156
|
+
if (blocker.startsWith("invalid_issue_or_pr:")) return true;
|
|
157
|
+
if (strict && (blocker === "workspace-not-git-repo" || blocker === "detached-head")) return true;
|
|
158
|
+
return false;
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Fallback liveness after owner routing failed: no reachable owner handled this CLI call. */
|
|
163
|
+
function ownerLiveFor(_state: SessionState): boolean {
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function pushUnique(out: string[], value: unknown): void {
|
|
168
|
+
if (typeof value === "string" && !out.includes(value)) out.push(value);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
interface CompletedTerminalEvent {
|
|
172
|
+
cursor: number;
|
|
173
|
+
createdAt: string;
|
|
174
|
+
kind: string;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function completedTerminalEvent(events: EventEnvelope[]): CompletedTerminalEvent | null {
|
|
178
|
+
for (const event of [...events].reverse()) {
|
|
179
|
+
const signal = (event.evidence as { signal?: unknown } | undefined)?.signal;
|
|
180
|
+
if (event.kind === "rpc_agent_completed" || signal === "completed") {
|
|
181
|
+
return { cursor: event.cursor, createdAt: event.createdAt, kind: event.kind };
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function buildObservation(
|
|
188
|
+
root: string,
|
|
189
|
+
state: SessionState,
|
|
190
|
+
ownerLive: boolean,
|
|
191
|
+
): Promise<{
|
|
192
|
+
observation: Observation;
|
|
193
|
+
completedTerminalEvent: CompletedTerminalEvent | null;
|
|
194
|
+
}> {
|
|
195
|
+
const workspace = state.handle.workspace;
|
|
196
|
+
const { gitDelta, branch, deleted } = gitDeltaFor(workspace);
|
|
197
|
+
const events = await readEvents(root, state.sessionId, 0);
|
|
198
|
+
const observedSignals = ["SessionStart"];
|
|
199
|
+
for (const event of events.slice(-200)) {
|
|
200
|
+
pushUnique(observedSignals, (event.evidence as { signal?: unknown } | undefined)?.signal);
|
|
201
|
+
}
|
|
202
|
+
const terminalEvent = completedTerminalEvent(events);
|
|
203
|
+
const lastEventAt = events.at(-1)?.createdAt;
|
|
204
|
+
return {
|
|
205
|
+
observation: {
|
|
206
|
+
lifecycle: state.lifecycle,
|
|
207
|
+
ownerLive,
|
|
208
|
+
cwd: workspace,
|
|
209
|
+
branch: branch ?? state.handle.branch,
|
|
210
|
+
gitDelta,
|
|
211
|
+
lastActivityAt: lastEventAt ?? state.updatedAt,
|
|
212
|
+
observedSignals,
|
|
213
|
+
risk: deleted ? "deleted-worktree" : !ownerLive && gitDelta === "dirty" ? "vanished-dirty" : "normal",
|
|
214
|
+
},
|
|
215
|
+
completedTerminalEvent: terminalEvent,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function isOwnerLivenessBlocker(blocker: string): boolean {
|
|
220
|
+
return blocker === "detached-owner-not-live" || blocker.startsWith("owner-vanished:");
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function reconcileCompletedOwnerExited(
|
|
224
|
+
root: string,
|
|
225
|
+
state: SessionState,
|
|
226
|
+
observation: Observation,
|
|
227
|
+
completedTerminal: CompletedTerminalEvent | null,
|
|
228
|
+
): Promise<SessionState> {
|
|
229
|
+
if (!completedTerminal || observation.ownerLive || observation.gitDelta !== "clean") return state;
|
|
230
|
+
if (state.lifecycle === "completed" || state.lifecycle === "retired") return state;
|
|
231
|
+
state.lifecycle = "completed";
|
|
232
|
+
state.blockers = state.blockers.filter(blocker => !isOwnerLivenessBlocker(blocker));
|
|
233
|
+
state.updatedAt = nowIso();
|
|
234
|
+
await writeSessionState(root, state);
|
|
235
|
+
return state;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function needsVanishedOwnerBlock(
|
|
239
|
+
state: SessionState,
|
|
240
|
+
observation: Observation,
|
|
241
|
+
completedTerminal: CompletedTerminalEvent | null,
|
|
242
|
+
): boolean {
|
|
243
|
+
if (observation.ownerLive || state.lifecycle !== "observing") return false;
|
|
244
|
+
if (completedTerminal || observation.observedSignals.includes("completed")) return false;
|
|
245
|
+
return observation.observedSignals.some(
|
|
246
|
+
signal => signal === "prompt-accepted" || signal === "tool-call" || signal === "streaming",
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function markVanishedOwnerBlocked(
|
|
251
|
+
root: string,
|
|
252
|
+
state: SessionState,
|
|
253
|
+
observation: Observation,
|
|
254
|
+
completedTerminal: CompletedTerminalEvent | null,
|
|
255
|
+
): Promise<SessionState> {
|
|
256
|
+
if (!needsVanishedOwnerBlock(state, observation, completedTerminal)) return state;
|
|
257
|
+
const blocker = `owner-vanished:${observation.gitDelta}`;
|
|
258
|
+
state.lifecycle = "blocked";
|
|
259
|
+
state.blockers = state.blockers.includes(blocker) ? state.blockers : [...state.blockers, blocker];
|
|
260
|
+
state.updatedAt = nowIso();
|
|
261
|
+
await writeSessionState(root, state);
|
|
262
|
+
return state;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function resolveRetryBudget(input: Record<string, unknown>): RetryBudget {
|
|
266
|
+
const supplied = input.retryBudget;
|
|
267
|
+
if (supplied && typeof supplied === "object" && !Array.isArray(supplied)) {
|
|
268
|
+
return { ...DEFAULT_RETRY_BUDGET, ...(supplied as Partial<RetryBudget>) };
|
|
269
|
+
}
|
|
270
|
+
return { ...DEFAULT_RETRY_BUDGET };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
interface OwnerSpawnResult {
|
|
274
|
+
live: boolean;
|
|
275
|
+
runtime: "tmux" | "detached" | "manual";
|
|
276
|
+
tmuxSessionName: string | null;
|
|
277
|
+
fallbackReason: string | null;
|
|
278
|
+
blockerReason: string | null;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function shellQuote(value: string): string {
|
|
282
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function deterministicHarnessTmuxSessionName(sessionId: string): string {
|
|
286
|
+
return `gajae_code_harness_${sanitizeTmuxToken(sessionId)}`;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async function loadState(root: string, sessionId: string): Promise<SessionState> {
|
|
290
|
+
const state = await readSessionState(root, sessionId);
|
|
291
|
+
if (!state) throw new Error(`session_not_found:${sessionId}`);
|
|
292
|
+
return state;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function requireSessionId(input: Record<string, unknown>, flagSession: string | undefined): string {
|
|
296
|
+
const id = flagSession ?? (typeof input.sessionId === "string" ? input.sessionId : undefined);
|
|
297
|
+
if (!id) throw new Error("missing_session_id");
|
|
298
|
+
return id;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export default class Harness extends Command {
|
|
302
|
+
static description = "Operate coding harnesses (v1: gajae-code) as a session/evidence/recovery/PR control plane";
|
|
303
|
+
static strict = false;
|
|
304
|
+
|
|
305
|
+
static args = {
|
|
306
|
+
verb: Args.string({
|
|
307
|
+
description: "start|preflight|submit|observe|classify|recover|validate|finalize|retire|events|monitor|operate",
|
|
308
|
+
required: true,
|
|
309
|
+
}),
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
static flags = {
|
|
313
|
+
input: Flags.string({ description: "JSON object input for the verb", default: "" }),
|
|
314
|
+
session: Flags.string({ char: "s", description: "Session id (re-grab a session)" }),
|
|
315
|
+
cursor: Flags.string({ description: "Event cursor for events --follow (exclusive)", default: "0" }),
|
|
316
|
+
follow: Flags.boolean({ description: "Tail the owner-written event log", default: false }),
|
|
317
|
+
json: Flags.boolean({ char: "j", description: "Emit machine-readable JSON", default: true }),
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
static examples = [
|
|
321
|
+
`gjc harness start --input '{"harness":"gajae-code","workspace":".","branch":"feat/x"}'`,
|
|
322
|
+
"gjc harness observe --session <id>",
|
|
323
|
+
`gjc harness classify --input '{"observation":{"ownerLive":false,"gitDelta":"dirty","risk":"vanished-dirty"}}'`,
|
|
324
|
+
"gjc harness events --session <id> --follow",
|
|
325
|
+
];
|
|
326
|
+
|
|
327
|
+
async run(): Promise<void> {
|
|
328
|
+
const { args, flags } = await this.parse(Harness);
|
|
329
|
+
const verb = String(args.verb);
|
|
330
|
+
const root = resolveHarnessRoot();
|
|
331
|
+
try {
|
|
332
|
+
const input = parseInput(flags.input);
|
|
333
|
+
switch (verb) {
|
|
334
|
+
case "start":
|
|
335
|
+
return await this.#start(root, input);
|
|
336
|
+
case "preflight":
|
|
337
|
+
return this.#preflight(input);
|
|
338
|
+
case "observe":
|
|
339
|
+
return await this.#observe(root, input, flags.session);
|
|
340
|
+
case "classify":
|
|
341
|
+
return await this.#classify(root, input, flags.session);
|
|
342
|
+
case "submit":
|
|
343
|
+
return await this.#submit(root, input, flags.session);
|
|
344
|
+
case "events":
|
|
345
|
+
case "monitor":
|
|
346
|
+
return await this.#events(root, input, flags.session, Number(flags.cursor) || 0);
|
|
347
|
+
case "retire":
|
|
348
|
+
return await this.#retire(root, input, flags.session);
|
|
349
|
+
case "finalize":
|
|
350
|
+
return await this.#finalizeVerb(root, input, flags.session);
|
|
351
|
+
case "__owner":
|
|
352
|
+
return await this.#runOwner(root, input, flags.session);
|
|
353
|
+
case "recover":
|
|
354
|
+
case "validate":
|
|
355
|
+
case "operate":
|
|
356
|
+
return await this.#ownerVerbOrPending(root, verb, input, flags.session);
|
|
357
|
+
default:
|
|
358
|
+
throw new Error(`unknown_harness_verb:${verb}`);
|
|
359
|
+
}
|
|
360
|
+
} catch (error) {
|
|
361
|
+
writeJson({ ok: false, error: error instanceof Error ? error.message : String(error), verb });
|
|
362
|
+
process.exitCode = 1;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
#preflight(input: Record<string, unknown>): void {
|
|
367
|
+
const preflight = buildPreflight(input);
|
|
368
|
+
writeJson({
|
|
369
|
+
ok: preflight.ok,
|
|
370
|
+
evidence: {
|
|
371
|
+
preflight,
|
|
372
|
+
guidance: preflight.ok
|
|
373
|
+
? "workspace metadata is normalized"
|
|
374
|
+
: "fix blockers before gjc harness start; branch must match the actual checkout and issueOrPr must be numeric or a recognized PR/issue form",
|
|
375
|
+
},
|
|
376
|
+
});
|
|
377
|
+
if (!preflight.ok) process.exitCode = 1;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async #finalizeVerb(root: string, input: Record<string, unknown>, flagSession: string | undefined): Promise<void> {
|
|
381
|
+
const sessionId = requireSessionId(input, flagSession);
|
|
382
|
+
if (await this.#tryOwnerRoute(root, sessionId, "finalize", { ...input, sessionId })) return;
|
|
383
|
+
// finalize is owner-routed; without a live owner, report owner-not-live (start the owner first).
|
|
384
|
+
const state = await loadState(root, sessionId);
|
|
385
|
+
writeJson(buildResponse(state, false, { completed: false, reason: "owner-not-live" }, false));
|
|
386
|
+
process.exitCode = 1;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** Route an owner-backed verb to the live owner; fall back to a pending response when none. */
|
|
390
|
+
async #ownerVerbOrPending(
|
|
391
|
+
root: string,
|
|
392
|
+
verb: string,
|
|
393
|
+
input: Record<string, unknown>,
|
|
394
|
+
flagSession: string | undefined,
|
|
395
|
+
): Promise<void> {
|
|
396
|
+
const sessionId = flagSession ?? (typeof input.sessionId === "string" ? input.sessionId : undefined);
|
|
397
|
+
if (sessionId && (await this.#tryOwnerRoute(root, sessionId, verb, { ...input, sessionId }))) return;
|
|
398
|
+
if (verb === "recover" && sessionId) return this.#recoverWithoutOwner(root, sessionId, input);
|
|
399
|
+
return this.#pending(root, verb, input, flagSession);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** Detached owner daemon (spawned by `start --detach`). Runs until retired or signalled. */
|
|
403
|
+
async #runOwner(root: string, input: Record<string, unknown>, flagSession: string | undefined): Promise<void> {
|
|
404
|
+
const sessionId = requireSessionId(input, flagSession);
|
|
405
|
+
const sessionDir = sessionPaths(root, sessionId).gjcSessionDir;
|
|
406
|
+
// Optional rpc command override (tests / non-default hosts); defaults to `gjc --mode rpc`.
|
|
407
|
+
const override = process.env.GJC_HARNESS_RPC_COMMAND;
|
|
408
|
+
const command = override ? (JSON.parse(override) as string[]) : undefined;
|
|
409
|
+
const rpc = new GajaeCodeRpc({ sessionDir, command });
|
|
410
|
+
const owner = new RuntimeOwner({ root, sessionId, rpc });
|
|
411
|
+
const info = await owner.start();
|
|
412
|
+
writeJson({ ok: true, owner: info });
|
|
413
|
+
await new Promise<void>(resolve => {
|
|
414
|
+
const stop = (): void => {
|
|
415
|
+
clearInterval(timer);
|
|
416
|
+
resolve();
|
|
417
|
+
};
|
|
418
|
+
const timer = setInterval(async () => {
|
|
419
|
+
const resolved = await resolveOwner(root, sessionId);
|
|
420
|
+
if (!resolved.live) stop();
|
|
421
|
+
}, 500);
|
|
422
|
+
timer.unref?.();
|
|
423
|
+
process.on("SIGTERM", stop);
|
|
424
|
+
process.on("SIGINT", stop);
|
|
425
|
+
});
|
|
426
|
+
await owner.stop();
|
|
427
|
+
process.exit(0);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
#buildOwnerCommand(sessionId: string): string[] {
|
|
431
|
+
const argv1 = process.argv[1];
|
|
432
|
+
return argv1
|
|
433
|
+
? [process.execPath, argv1, "harness", "__owner", "--session", sessionId]
|
|
434
|
+
: [process.execPath, "harness", "__owner", "--session", sessionId];
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
async #waitForOwner(root: string, sessionId: string): Promise<boolean> {
|
|
438
|
+
for (let i = 0; i < 100; i++) {
|
|
439
|
+
const owner = await resolveOwner(root, sessionId);
|
|
440
|
+
if (owner.live && owner.socketPath) {
|
|
441
|
+
try {
|
|
442
|
+
await callEndpoint(owner.socketPath, { verb: "observe", input: { sessionId } }, 250);
|
|
443
|
+
return true;
|
|
444
|
+
} catch (error) {
|
|
445
|
+
if (!(error instanceof EndpointUnreachableError)) throw error;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
await new Promise(r => setTimeout(r, 50));
|
|
449
|
+
}
|
|
450
|
+
return false;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
#startTmuxResidentOwner(
|
|
454
|
+
root: string,
|
|
455
|
+
sessionId: string,
|
|
456
|
+
cwd: string,
|
|
457
|
+
): { started: boolean; sessionName: string; reason: string | null } {
|
|
458
|
+
const tmuxCommand = resolveGjcTmuxCommand();
|
|
459
|
+
if (Bun.which(tmuxCommand) === null) {
|
|
460
|
+
return {
|
|
461
|
+
started: false,
|
|
462
|
+
sessionName: deterministicHarnessTmuxSessionName(sessionId),
|
|
463
|
+
reason: "tmux-unavailable",
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
const sessionName = deterministicHarnessTmuxSessionName(sessionId);
|
|
467
|
+
const envAssignments = [`GJC_HARNESS_STATE_ROOT=${shellQuote(root)}`];
|
|
468
|
+
if (process.env.GJC_HARNESS_RPC_COMMAND) {
|
|
469
|
+
envAssignments.push(`GJC_HARNESS_RPC_COMMAND=${shellQuote(process.env.GJC_HARNESS_RPC_COMMAND)}`);
|
|
470
|
+
}
|
|
471
|
+
const ownerCommand = this.#buildOwnerCommand(sessionId).map(shellQuote).join(" ");
|
|
472
|
+
const shellCommand = `exec env ${envAssignments.join(" ")} ${ownerCommand}`;
|
|
473
|
+
const created = Bun.spawnSync([tmuxCommand, "new-session", "-d", "-s", sessionName, "-c", cwd, shellCommand], {
|
|
474
|
+
stdout: "pipe",
|
|
475
|
+
stderr: "pipe",
|
|
476
|
+
env: process.env,
|
|
477
|
+
});
|
|
478
|
+
if (created.exitCode === 0) return { started: true, sessionName, reason: null };
|
|
479
|
+
const stderr = created.stderr.toString().trim();
|
|
480
|
+
return { started: false, sessionName, reason: stderr || "tmux-start-failed" };
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/** Spawn the owner daemon. Prefer a tmux-resident owner, then explicitly fall back to detached. */
|
|
484
|
+
async #spawnDetachedOwner(root: string, sessionId: string, cwd: string): Promise<OwnerSpawnResult> {
|
|
485
|
+
const tmux = this.#startTmuxResidentOwner(root, sessionId, cwd);
|
|
486
|
+
if (tmux.started && (await this.#waitForOwner(root, sessionId))) {
|
|
487
|
+
return {
|
|
488
|
+
live: true,
|
|
489
|
+
runtime: "tmux",
|
|
490
|
+
tmuxSessionName: tmux.sessionName,
|
|
491
|
+
fallbackReason: null,
|
|
492
|
+
blockerReason: null,
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
const fallbackReason = tmux.started
|
|
496
|
+
? "tmux new-session exited 0 but owner endpoint did not become routable"
|
|
497
|
+
: tmux.reason;
|
|
498
|
+
const cmd = this.#buildOwnerCommand(sessionId);
|
|
499
|
+
const child = Bun.spawn(cmd, {
|
|
500
|
+
cwd,
|
|
501
|
+
env: { ...process.env, GJC_HARNESS_STATE_ROOT: root },
|
|
502
|
+
stdout: "ignore",
|
|
503
|
+
stderr: "ignore",
|
|
504
|
+
stdin: "ignore",
|
|
505
|
+
});
|
|
506
|
+
child.unref();
|
|
507
|
+
const live = await this.#waitForOwner(root, sessionId);
|
|
508
|
+
return {
|
|
509
|
+
live,
|
|
510
|
+
runtime: "detached",
|
|
511
|
+
tmuxSessionName: null,
|
|
512
|
+
fallbackReason,
|
|
513
|
+
blockerReason: live ? null : "detached-owner-not-live",
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
async #start(root: string, input: Record<string, unknown>): Promise<void> {
|
|
518
|
+
const harness = (typeof input.harness === "string" ? input.harness : "gajae-code") as HarnessKind;
|
|
519
|
+
if (harness !== "gajae-code") {
|
|
520
|
+
writeJson({
|
|
521
|
+
ok: false,
|
|
522
|
+
error: `harness_unsupported_in_v1:${harness}`,
|
|
523
|
+
evidence: { seam: true, supported: ["gajae-code"] },
|
|
524
|
+
});
|
|
525
|
+
process.exitCode = 1;
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
const preflight = buildPreflight(input);
|
|
529
|
+
const fatalBlockers = startFatalPreflightBlockers(input, preflight);
|
|
530
|
+
if (fatalBlockers.length > 0) {
|
|
531
|
+
writeJson({
|
|
532
|
+
ok: false,
|
|
533
|
+
error: "harness_preflight_failed",
|
|
534
|
+
evidence: {
|
|
535
|
+
preflight: { ...preflight, blockers: fatalBlockers, ok: false },
|
|
536
|
+
guidance:
|
|
537
|
+
"fix blockers before start; run gjc harness preflight with the same input for branch and issue/PR diagnostics",
|
|
538
|
+
},
|
|
539
|
+
});
|
|
540
|
+
process.exitCode = 1;
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
const workspace = typeof input.workspace === "string" ? input.workspace : process.cwd();
|
|
544
|
+
const sessionId = typeof input.sessionId === "string" ? input.sessionId : generateSessionId();
|
|
545
|
+
const eventsPath = `${root}/sessions/${sessionId}/events.jsonl`;
|
|
546
|
+
const leasePath = `${root}/sessions/${sessionId}/lease.json`;
|
|
547
|
+
const startedAt = nowIso();
|
|
548
|
+
const handle: SessionHandle = {
|
|
549
|
+
sessionId,
|
|
550
|
+
harness,
|
|
551
|
+
repo: typeof input.repo === "string" ? input.repo : null,
|
|
552
|
+
workspace,
|
|
553
|
+
branch: preflight.declaredBranch ?? preflight.actualBranch,
|
|
554
|
+
base: typeof input.base === "string" ? input.base : null,
|
|
555
|
+
issueOrPr: preflight.normalizedIssueOrPr,
|
|
556
|
+
processHandle: { kind: "runtime-owner", ownerId: null, pid: null },
|
|
557
|
+
rpcHandle: { kind: "rpc-subprocess", pid: null, sessionDir: `${root}/sessions/${sessionId}/gjc-session` },
|
|
558
|
+
ownerHandle: { leasePath, endpoint: null, heartbeatAt: null },
|
|
559
|
+
routerHandle: { kind: "default-in-owner", policy: "default-fallback", eventsPath },
|
|
560
|
+
viewportHandle: { kind: "event-monitor", tmuxSessionName: null, viewOnly: true },
|
|
561
|
+
startedAt,
|
|
562
|
+
updatedAt: startedAt,
|
|
563
|
+
};
|
|
564
|
+
const state: SessionState = {
|
|
565
|
+
schemaVersion: SESSION_SCHEMA_VERSION,
|
|
566
|
+
sessionId,
|
|
567
|
+
lifecycle: "started",
|
|
568
|
+
harness,
|
|
569
|
+
handle,
|
|
570
|
+
retries: {},
|
|
571
|
+
blockers: [],
|
|
572
|
+
createdAt: startedAt,
|
|
573
|
+
updatedAt: startedAt,
|
|
574
|
+
};
|
|
575
|
+
await writeSessionState(root, state);
|
|
576
|
+
let ownerLive = false;
|
|
577
|
+
let ownerRuntime: OwnerSpawnResult["runtime"] = "manual";
|
|
578
|
+
let ownerFallbackReason: string | null = null;
|
|
579
|
+
let ownerBlockerReason: string | null = null;
|
|
580
|
+
if (input.detach === true) {
|
|
581
|
+
const ownerSpawn = await this.#spawnDetachedOwner(root, sessionId, workspace);
|
|
582
|
+
ownerLive = ownerSpawn.live;
|
|
583
|
+
ownerRuntime = ownerSpawn.runtime;
|
|
584
|
+
ownerFallbackReason = ownerSpawn.fallbackReason;
|
|
585
|
+
ownerBlockerReason = ownerSpawn.blockerReason;
|
|
586
|
+
handle.viewportHandle = {
|
|
587
|
+
kind: "event-monitor",
|
|
588
|
+
tmuxSessionName: ownerSpawn.tmuxSessionName,
|
|
589
|
+
viewOnly: true,
|
|
590
|
+
};
|
|
591
|
+
if (ownerLive) {
|
|
592
|
+
const resolved = await resolveOwner(root, sessionId);
|
|
593
|
+
handle.processHandle = {
|
|
594
|
+
kind: "runtime-owner",
|
|
595
|
+
ownerId: resolved.lease?.ownerId ?? null,
|
|
596
|
+
pid: resolved.lease?.pid ?? null,
|
|
597
|
+
};
|
|
598
|
+
handle.ownerHandle = {
|
|
599
|
+
leasePath,
|
|
600
|
+
endpoint: resolved.socketPath,
|
|
601
|
+
heartbeatAt: resolved.lease?.heartbeatAt ?? null,
|
|
602
|
+
};
|
|
603
|
+
state.handle = handle;
|
|
604
|
+
await writeSessionState(root, state);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
if (ownerBlockerReason) {
|
|
608
|
+
const resolved = await resolveOwner(root, sessionId);
|
|
609
|
+
if (resolved.live && resolved.socketPath) {
|
|
610
|
+
ownerLive = true;
|
|
611
|
+
ownerBlockerReason = null;
|
|
612
|
+
handle.processHandle = {
|
|
613
|
+
kind: "runtime-owner",
|
|
614
|
+
ownerId: resolved.lease?.ownerId ?? null,
|
|
615
|
+
pid: resolved.lease?.pid ?? null,
|
|
616
|
+
};
|
|
617
|
+
handle.ownerHandle = {
|
|
618
|
+
leasePath,
|
|
619
|
+
endpoint: resolved.socketPath,
|
|
620
|
+
heartbeatAt: resolved.lease?.heartbeatAt ?? null,
|
|
621
|
+
};
|
|
622
|
+
state.handle = handle;
|
|
623
|
+
await writeSessionState(root, state);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
if (ownerBlockerReason) {
|
|
627
|
+
state.lifecycle = "blocked";
|
|
628
|
+
state.blockers = [...state.blockers, ownerBlockerReason];
|
|
629
|
+
state.handle = handle;
|
|
630
|
+
state.updatedAt = nowIso();
|
|
631
|
+
await writeSessionState(root, state);
|
|
632
|
+
}
|
|
633
|
+
writeJson(
|
|
634
|
+
buildResponse(
|
|
635
|
+
state,
|
|
636
|
+
ownerLive,
|
|
637
|
+
{
|
|
638
|
+
handle,
|
|
639
|
+
ownerRuntime,
|
|
640
|
+
preflight,
|
|
641
|
+
...(ownerFallbackReason ? { ownerFallbackReason } : {}),
|
|
642
|
+
...(ownerBlockerReason ? { reason: ownerBlockerReason } : {}),
|
|
643
|
+
},
|
|
644
|
+
!ownerBlockerReason,
|
|
645
|
+
),
|
|
646
|
+
);
|
|
647
|
+
if (ownerBlockerReason) process.exitCode = 1;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/** Returns true if a live owner handled the verb (response already printed). */
|
|
651
|
+
async #tryOwnerRoute(
|
|
652
|
+
root: string,
|
|
653
|
+
sessionId: string,
|
|
654
|
+
verb: string,
|
|
655
|
+
input: Record<string, unknown>,
|
|
656
|
+
): Promise<boolean> {
|
|
657
|
+
const owner = await resolveOwner(root, sessionId);
|
|
658
|
+
if (!owner.live || !owner.socketPath) return false;
|
|
659
|
+
try {
|
|
660
|
+
const res = (await callEndpoint(owner.socketPath, { verb, input })) as { ok?: boolean };
|
|
661
|
+
writeJson(res);
|
|
662
|
+
if (res?.ok === false) process.exitCode = 1;
|
|
663
|
+
return true;
|
|
664
|
+
} catch (error) {
|
|
665
|
+
if (error instanceof EndpointUnreachableError) return false;
|
|
666
|
+
throw error;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
async #observe(root: string, input: Record<string, unknown>, flagSession: string | undefined): Promise<void> {
|
|
671
|
+
const sessionId = requireSessionId(input, flagSession);
|
|
672
|
+
if (await this.#tryOwnerRoute(root, sessionId, "observe", { ...input, sessionId })) return;
|
|
673
|
+
let state = await loadState(root, sessionId);
|
|
674
|
+
const ownerLive = ownerLiveFor(state);
|
|
675
|
+
const { observation, completedTerminalEvent } = await buildObservation(root, state, ownerLive);
|
|
676
|
+
state = await reconcileCompletedOwnerExited(root, state, observation, completedTerminalEvent);
|
|
677
|
+
const vanishedOwnerBlock = needsVanishedOwnerBlock(state, observation, completedTerminalEvent);
|
|
678
|
+
state = await markVanishedOwnerBlocked(root, state, observation, completedTerminalEvent);
|
|
679
|
+
writeJson(
|
|
680
|
+
buildResponse(state, ownerLive, {
|
|
681
|
+
observation: { ...observation, lifecycle: state.lifecycle },
|
|
682
|
+
readOnly: !ownerLive,
|
|
683
|
+
...(vanishedOwnerBlock
|
|
684
|
+
? { ownerVanished: true, blockerReason: `owner-vanished:${observation.gitDelta}` }
|
|
685
|
+
: {}),
|
|
686
|
+
...(completedTerminalEvent && !ownerLive
|
|
687
|
+
? { completedOwnerExited: true, terminalResult: completedTerminalEvent }
|
|
688
|
+
: {}),
|
|
689
|
+
}),
|
|
690
|
+
);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
async #classify(root: string, input: Record<string, unknown>, flagSession: string | undefined): Promise<void> {
|
|
694
|
+
const budget = resolveRetryBudget(input);
|
|
695
|
+
let observation = input.observation as Partial<Observation> | undefined;
|
|
696
|
+
let stateView: SessionState | null = null;
|
|
697
|
+
const sessionId = flagSession ?? (typeof input.sessionId === "string" ? input.sessionId : undefined);
|
|
698
|
+
if (sessionId) {
|
|
699
|
+
stateView = await loadState(root, sessionId);
|
|
700
|
+
if (!observation) {
|
|
701
|
+
const built = await buildObservation(root, stateView, ownerLiveFor(stateView));
|
|
702
|
+
observation = built.observation;
|
|
703
|
+
stateView = await markVanishedOwnerBlocked(
|
|
704
|
+
root,
|
|
705
|
+
stateView,
|
|
706
|
+
built.observation,
|
|
707
|
+
built.completedTerminalEvent,
|
|
708
|
+
);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
if (!observation) throw new Error("classify_requires_observation_or_session");
|
|
712
|
+
const full: Observation = {
|
|
713
|
+
lifecycle: observation.lifecycle ?? "observing",
|
|
714
|
+
ownerLive: observation.ownerLive ?? false,
|
|
715
|
+
cwd: observation.cwd ?? ".",
|
|
716
|
+
branch: observation.branch ?? null,
|
|
717
|
+
gitDelta: observation.gitDelta ?? "unknown",
|
|
718
|
+
lastActivityAt: observation.lastActivityAt ?? null,
|
|
719
|
+
observedSignals: observation.observedSignals ?? [],
|
|
720
|
+
risk: observation.risk ?? "normal",
|
|
721
|
+
};
|
|
722
|
+
const decision = classifyRecovery({ observation: full, retryBudget: budget });
|
|
723
|
+
if (stateView) {
|
|
724
|
+
writeJson(
|
|
725
|
+
buildResponse(stateView, ownerLiveFor(stateView), {
|
|
726
|
+
decision,
|
|
727
|
+
observation: { ...full, lifecycle: stateView.lifecycle },
|
|
728
|
+
}),
|
|
729
|
+
);
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
// Pure classify without a session: synthesize a minimal state view.
|
|
733
|
+
writeJson({
|
|
734
|
+
ok: true,
|
|
735
|
+
state: {
|
|
736
|
+
sessionId: "(none)",
|
|
737
|
+
lifecycle: full.lifecycle,
|
|
738
|
+
harness: "gajae-code",
|
|
739
|
+
ownerLive: full.ownerLive,
|
|
740
|
+
blockers: [],
|
|
741
|
+
},
|
|
742
|
+
evidence: { decision, observation: full },
|
|
743
|
+
nextAllowedActions: [],
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
async #submit(root: string, input: Record<string, unknown>, flagSession: string | undefined): Promise<void> {
|
|
748
|
+
const sessionId = requireSessionId(input, flagSession);
|
|
749
|
+
if (await this.#tryOwnerRoute(root, sessionId, "submit", { ...input, sessionId })) return;
|
|
750
|
+
const state = await loadState(root, sessionId);
|
|
751
|
+
// No live owner: submission is blocked (never echoed-as-accepted).
|
|
752
|
+
writeJson(buildResponse(state, false, { accepted: false, submitted: false, reason: "owner-not-live" }, false));
|
|
753
|
+
process.exitCode = 1;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
async #events(
|
|
757
|
+
root: string,
|
|
758
|
+
input: Record<string, unknown>,
|
|
759
|
+
flagSession: string | undefined,
|
|
760
|
+
cursor: number,
|
|
761
|
+
): Promise<void> {
|
|
762
|
+
const sessionId = requireSessionId(input, flagSession);
|
|
763
|
+
const state = await loadState(root, sessionId);
|
|
764
|
+
const events = await readEvents(root, sessionId, cursor);
|
|
765
|
+
const nextCursor = events.length > 0 ? events[events.length - 1].cursor : cursor;
|
|
766
|
+
writeJson(
|
|
767
|
+
buildResponse(state, ownerLiveFor(state), {
|
|
768
|
+
events,
|
|
769
|
+
cursor: nextCursor,
|
|
770
|
+
note: "tail-only; live producer (owner) lands in M3/M5",
|
|
771
|
+
}),
|
|
772
|
+
);
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
async #retire(root: string, input: Record<string, unknown>, flagSession: string | undefined): Promise<void> {
|
|
776
|
+
const sessionId = requireSessionId(input, flagSession);
|
|
777
|
+
if (await this.#tryOwnerRoute(root, sessionId, "retire", { ...input, sessionId })) return;
|
|
778
|
+
const state = await loadState(root, sessionId);
|
|
779
|
+
const { observation } = await buildObservation(root, state, ownerLiveFor(state));
|
|
780
|
+
if (observation.gitDelta === "dirty" || observation.gitDelta === "unknown") {
|
|
781
|
+
writeJson(
|
|
782
|
+
buildResponse(
|
|
783
|
+
state,
|
|
784
|
+
false,
|
|
785
|
+
{
|
|
786
|
+
retired: false,
|
|
787
|
+
reason: `retire-blocked:${observation.gitDelta}-delta`,
|
|
788
|
+
gitDelta: observation.gitDelta,
|
|
789
|
+
},
|
|
790
|
+
false,
|
|
791
|
+
),
|
|
792
|
+
);
|
|
793
|
+
process.exitCode = 1;
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
796
|
+
state.lifecycle = "retired";
|
|
797
|
+
state.updatedAt = nowIso();
|
|
798
|
+
await writeSessionState(root, state);
|
|
799
|
+
writeJson(buildResponse(state, false, { retired: true }));
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
async #recoverWithoutOwner(root: string, sessionId: string, input: Record<string, unknown>): Promise<void> {
|
|
803
|
+
const budget = resolveRetryBudget(input);
|
|
804
|
+
let state = await loadState(root, sessionId);
|
|
805
|
+
const { observation, completedTerminalEvent } = await buildObservation(root, state, false);
|
|
806
|
+
state = await markVanishedOwnerBlocked(root, state, observation, completedTerminalEvent);
|
|
807
|
+
const decision = classifyRecovery({
|
|
808
|
+
observation: { ...observation, lifecycle: state.lifecycle },
|
|
809
|
+
retryBudget: budget,
|
|
810
|
+
});
|
|
811
|
+
writeJson(
|
|
812
|
+
buildResponse(
|
|
813
|
+
state,
|
|
814
|
+
false,
|
|
815
|
+
{
|
|
816
|
+
pending: false,
|
|
817
|
+
reason: "owner-not-live",
|
|
818
|
+
decision,
|
|
819
|
+
observation: { ...observation, lifecycle: state.lifecycle },
|
|
820
|
+
},
|
|
821
|
+
false,
|
|
822
|
+
),
|
|
823
|
+
);
|
|
824
|
+
process.exitCode = 1;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
async #pending(
|
|
828
|
+
root: string,
|
|
829
|
+
verb: string,
|
|
830
|
+
input: Record<string, unknown>,
|
|
831
|
+
flagSession: string | undefined,
|
|
832
|
+
): Promise<void> {
|
|
833
|
+
const sessionId = flagSession ?? (typeof input.sessionId === "string" ? input.sessionId : undefined);
|
|
834
|
+
const milestone = verb === "recover" ? "M7" : verb === "validate" || verb === "finalize" ? "M8" : "M9";
|
|
835
|
+
if (sessionId) {
|
|
836
|
+
const state = await loadState(root, sessionId);
|
|
837
|
+
writeJson(buildResponse(state, ownerLiveFor(state), { pending: true, milestone, verb }, false));
|
|
838
|
+
process.exitCode = 1;
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
writeJson({
|
|
842
|
+
ok: false,
|
|
843
|
+
state: buildStateView(
|
|
844
|
+
{
|
|
845
|
+
schemaVersion: SESSION_SCHEMA_VERSION,
|
|
846
|
+
sessionId: "(none)",
|
|
847
|
+
lifecycle: "new",
|
|
848
|
+
harness: "gajae-code",
|
|
849
|
+
handle: {} as SessionHandle,
|
|
850
|
+
retries: {},
|
|
851
|
+
blockers: [],
|
|
852
|
+
createdAt: nowIso(),
|
|
853
|
+
updatedAt: nowIso(),
|
|
854
|
+
},
|
|
855
|
+
false,
|
|
856
|
+
),
|
|
857
|
+
evidence: { pending: true, milestone, verb },
|
|
858
|
+
nextAllowedActions: [],
|
|
859
|
+
});
|
|
860
|
+
process.exitCode = 1;
|
|
861
|
+
}
|
|
862
|
+
}
|