@gajae-code/coding-agent 0.4.5 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +62 -0
- package/dist/types/async/job-manager.d.ts +26 -0
- package/dist/types/cli/args.d.ts +1 -0
- package/dist/types/cli/list-models.d.ts +6 -0
- package/dist/types/commands/gc.d.ts +26 -0
- package/dist/types/commands/harness.d.ts +3 -0
- package/dist/types/config/file-lock-gc.d.ts +5 -0
- package/dist/types/config/file-lock.d.ts +7 -0
- package/dist/types/config/model-profile-activation.d.ts +11 -2
- package/dist/types/config/model-profiles.d.ts +7 -0
- package/dist/types/config/model-registry.d.ts +3 -0
- package/dist/types/config/model-resolver.d.ts +2 -0
- package/dist/types/config/models-config-schema.d.ts +30 -0
- package/dist/types/config/settings-schema.d.ts +4 -3
- package/dist/types/coordinator/contract.d.ts +1 -1
- package/dist/types/defaults/gjc/extensions/grok-build/index.d.ts +1 -0
- package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/index.d.ts +1 -0
- package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.d.ts +25 -0
- package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/payload/sanitize.d.ts +27 -0
- package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/billing.d.ts +8 -0
- package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/register.d.ts +5 -0
- package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/stream.d.ts +10 -0
- package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/provider/usage.d.ts +2 -0
- package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/shared/base-url.d.ts +2 -0
- package/dist/types/defaults/gjc/extensions/grok-cli-vendor/src/shared/errors.d.ts +38 -0
- package/dist/types/defaults/gjc-grok-cli.d.ts +5 -0
- package/dist/types/extensibility/extensions/index.d.ts +1 -0
- package/dist/types/extensibility/extensions/prefix-command-bridge.d.ts +35 -0
- package/dist/types/gjc-runtime/deep-interview-recorder.d.ts +103 -0
- package/dist/types/gjc-runtime/deep-interview-runtime.d.ts +2 -0
- package/dist/types/gjc-runtime/deep-interview-state.d.ts +112 -0
- package/dist/types/gjc-runtime/gc-render.d.ts +6 -0
- package/dist/types/gjc-runtime/gc-runtime.d.ts +134 -0
- package/dist/types/gjc-runtime/ledger-event-renderer.d.ts +68 -0
- package/dist/types/gjc-runtime/team-gc.d.ts +7 -0
- package/dist/types/gjc-runtime/team-runtime.d.ts +5 -1
- package/dist/types/gjc-runtime/tmux-common.d.ts +14 -0
- package/dist/types/gjc-runtime/tmux-gc.d.ts +7 -0
- package/dist/types/gjc-runtime/tmux-sessions.d.ts +13 -0
- package/dist/types/harness-control-plane/gc-adapter.d.ts +3 -0
- package/dist/types/harness-control-plane/owner.d.ts +8 -1
- package/dist/types/harness-control-plane/receipt-spool.d.ts +19 -0
- package/dist/types/harness-control-plane/state-machine.d.ts +6 -1
- package/dist/types/harness-control-plane/storage.d.ts +20 -0
- package/dist/types/harness-control-plane/types.d.ts +4 -0
- package/dist/types/hindsight/mental-models.d.ts +5 -5
- package/dist/types/modes/components/hook-selector.d.ts +7 -1
- package/dist/types/modes/components/model-selector.d.ts +1 -12
- package/dist/types/modes/controllers/command-controller.d.ts +1 -0
- package/dist/types/modes/rpc/rpc-client.d.ts +2 -2
- package/dist/types/modes/rpc/rpc-mode.d.ts +16 -1
- package/dist/types/modes/rpc/rpc-types.d.ts +4 -1
- package/dist/types/modes/shared/agent-wire/deep-interview-gate.d.ts +13 -0
- package/dist/types/modes/shared/agent-wire/session-registry.d.ts +25 -0
- package/dist/types/modes/shared/agent-wire/unattended-action-policy.d.ts +2 -0
- package/dist/types/sdk.d.ts +5 -0
- package/dist/types/session/agent-session.d.ts +3 -1
- package/dist/types/session/blob-store.d.ts +59 -4
- package/dist/types/session/session-manager.d.ts +24 -6
- package/dist/types/session/streaming-output.d.ts +3 -2
- package/dist/types/session/tool-choice-queue.d.ts +6 -0
- package/dist/types/skill-state/workflow-hud.d.ts +14 -0
- package/dist/types/task/receipt.d.ts +1 -0
- package/dist/types/task/types.d.ts +7 -0
- package/dist/types/thinking-metadata.d.ts +16 -0
- package/dist/types/thinking.d.ts +3 -12
- package/dist/types/tools/ask.d.ts +15 -1
- package/dist/types/tools/index.d.ts +2 -0
- package/dist/types/tools/resolve.d.ts +0 -10
- package/dist/types/tools/subagent.d.ts +6 -0
- package/dist/types/utils/tool-choice.d.ts +14 -1
- package/package.json +7 -7
- package/src/async/job-manager.ts +52 -0
- package/src/cli/args.ts +3 -0
- package/src/cli/auth-broker-cli.ts +1 -0
- package/src/cli/list-models.ts +13 -1
- package/src/cli.ts +9 -4
- package/src/commands/gc.ts +22 -0
- package/src/commands/harness.ts +43 -5
- package/src/commands/launch.ts +2 -2
- package/src/commands/session.ts +3 -1
- package/src/config/file-lock-gc.ts +181 -0
- package/src/config/file-lock.ts +14 -0
- package/src/config/model-profile-activation.ts +15 -3
- package/src/config/model-profiles.ts +264 -56
- package/src/config/model-resolver.ts +9 -6
- package/src/config/models-config-schema.ts +1 -0
- package/src/config/settings-schema.ts +6 -3
- package/src/coordinator/contract.ts +1 -0
- package/src/coordinator-mcp/server.ts +513 -26
- package/src/cursor.ts +16 -2
- package/src/defaults/gjc/agent.models.grok-cli.yml +36 -0
- package/src/defaults/gjc/extensions/grok-build/index.ts +1 -0
- package/src/defaults/gjc/extensions/grok-build/package.json +7 -0
- package/src/defaults/gjc/extensions/grok-cli-vendor/biome.json +39 -0
- package/src/defaults/gjc/extensions/grok-cli-vendor/package.json +8 -0
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/index.ts +1 -0
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.ts +155 -0
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/payload/sanitize.ts +361 -0
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/billing.ts +57 -0
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/register.ts +99 -0
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/stream.ts +50 -0
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/usage.ts +56 -0
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/shared/base-url.ts +36 -0
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/shared/errors.ts +44 -0
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +131 -113
- package/src/defaults/gjc/skills/deep-interview/lateral-review-panel.md +49 -0
- package/src/defaults/gjc/skills/team/SKILL.md +3 -2
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +8 -2
- package/src/defaults/gjc-defaults.ts +7 -0
- package/src/defaults/gjc-grok-cli.ts +22 -0
- package/src/export/html/index.ts +13 -9
- package/src/extensibility/extensions/index.ts +1 -0
- package/src/extensibility/extensions/prefix-command-bridge.ts +128 -0
- package/src/gjc-runtime/deep-interview-recorder.ts +417 -0
- package/src/gjc-runtime/deep-interview-runtime.ts +18 -26
- package/src/gjc-runtime/deep-interview-state.ts +324 -0
- package/src/gjc-runtime/gc-render.ts +70 -0
- package/src/gjc-runtime/gc-runtime.ts +403 -0
- package/src/gjc-runtime/ledger-event-renderer.ts +164 -0
- package/src/gjc-runtime/ralplan-runtime.ts +58 -7
- package/src/gjc-runtime/state-renderer.ts +12 -3
- package/src/gjc-runtime/state-runtime.ts +46 -29
- package/src/gjc-runtime/team-gc.ts +49 -0
- package/src/gjc-runtime/team-runtime.ts +211 -8
- package/src/gjc-runtime/tmux-common.ts +29 -0
- package/src/gjc-runtime/tmux-gc.ts +176 -0
- package/src/gjc-runtime/tmux-sessions.ts +68 -12
- package/src/gjc-runtime/ultragoal-runtime.ts +517 -41
- package/src/gjc-runtime/workflow-manifest.generated.json +27 -1
- package/src/gjc-runtime/workflow-manifest.ts +16 -1
- package/src/harness-control-plane/gc-adapter.ts +184 -0
- package/src/harness-control-plane/owner.ts +89 -27
- package/src/harness-control-plane/receipt-spool.ts +128 -0
- package/src/harness-control-plane/state-machine.ts +27 -6
- package/src/harness-control-plane/storage.ts +93 -0
- package/src/harness-control-plane/types.ts +4 -0
- package/src/hindsight/mental-models.ts +17 -16
- package/src/internal-urls/docs-index.generated.ts +14 -8
- package/src/main.ts +7 -2
- package/src/modes/components/assistant-message.ts +26 -14
- package/src/modes/components/diff.ts +97 -0
- package/src/modes/components/hook-selector.ts +19 -0
- package/src/modes/components/model-selector.ts +370 -181
- package/src/modes/components/status-line/segments.ts +1 -1
- package/src/modes/components/tool-execution.ts +30 -13
- package/src/modes/controllers/command-controller.ts +25 -6
- package/src/modes/controllers/extension-ui-controller.ts +3 -0
- package/src/modes/controllers/selector-controller.ts +34 -42
- package/src/modes/rpc/rpc-client.ts +3 -2
- package/src/modes/rpc/rpc-mode.ts +187 -39
- package/src/modes/rpc/rpc-types.ts +5 -2
- package/src/modes/shared/agent-wire/command-dispatch.ts +279 -257
- package/src/modes/shared/agent-wire/command-validation.ts +11 -0
- package/src/modes/shared/agent-wire/deep-interview-gate.ts +30 -1
- package/src/modes/shared/agent-wire/session-registry.ts +109 -0
- package/src/modes/shared/agent-wire/unattended-action-policy.ts +24 -0
- package/src/modes/shared/agent-wire/unattended-run-controller.ts +23 -3
- package/src/modes/shared/agent-wire/unattended-session.ts +16 -1
- package/src/sdk.ts +46 -5
- package/src/secrets/obfuscator.ts +102 -27
- package/src/session/agent-session.ts +179 -25
- package/src/session/blob-store.ts +148 -6
- package/src/session/session-manager.ts +311 -60
- package/src/session/streaming-output.ts +185 -122
- package/src/session/tool-choice-queue.ts +23 -0
- package/src/setup/hermes/templates/operator-instructions.v1.md +7 -1
- package/src/skill-state/workflow-hud.ts +106 -10
- package/src/slash-commands/builtin-registry.ts +3 -2
- package/src/task/executor.ts +78 -6
- package/src/task/receipt.ts +5 -0
- package/src/task/render.ts +21 -1
- package/src/task/types.ts +8 -0
- package/src/thinking-metadata.ts +51 -0
- package/src/thinking.ts +26 -46
- package/src/tools/ask.ts +56 -1
- package/src/tools/bash.ts +1 -1
- package/src/tools/index.ts +2 -0
- package/src/tools/job.ts +3 -2
- package/src/tools/monitor.ts +36 -1
- package/src/tools/resolve.ts +93 -18
- package/src/tools/subagent-render.ts +9 -0
- package/src/tools/subagent.ts +26 -2
- package/src/utils/edit-mode.ts +1 -1
- package/src/utils/tool-choice.ts +45 -16
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
type GcCollectResult,
|
|
5
|
+
type GcContext,
|
|
6
|
+
type GcError,
|
|
7
|
+
type GcPruneOutcome,
|
|
8
|
+
type GcRecord,
|
|
9
|
+
type GcStoreAdapter,
|
|
10
|
+
gcPidStatusLabel,
|
|
11
|
+
gcProbeToLeasePidStatus,
|
|
12
|
+
} from "../gjc-runtime/gc-runtime";
|
|
13
|
+
import { classifyLeaseStatus, readLease, reapDeadOwnerArtifacts } from "./session-lease";
|
|
14
|
+
import {
|
|
15
|
+
type HarnessRootRegistryForGc,
|
|
16
|
+
type HarnessRootRegistryListingForGc,
|
|
17
|
+
listHarnessRootRegistriesForGc,
|
|
18
|
+
removeHarnessRootRegistryFileForGc,
|
|
19
|
+
rewriteHarnessRootRegistryForGc,
|
|
20
|
+
sessionPaths,
|
|
21
|
+
} from "./storage";
|
|
22
|
+
|
|
23
|
+
function errorMessage(error: unknown): string {
|
|
24
|
+
return error instanceof Error ? error.message : String(error);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function exists(file: string): Promise<boolean> {
|
|
28
|
+
try {
|
|
29
|
+
await fs.access(file);
|
|
30
|
+
return true;
|
|
31
|
+
} catch (error) {
|
|
32
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function registryErrors(registries: HarnessRootRegistryListingForGc[]): GcError[] {
|
|
38
|
+
return registries
|
|
39
|
+
.filter(registry => registry.error)
|
|
40
|
+
.map(registry => ({
|
|
41
|
+
store: "registry_entries",
|
|
42
|
+
scope: registry.file,
|
|
43
|
+
message: registry.error ?? "registry_error",
|
|
44
|
+
}));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function collectRegistries(ctx: GcContext): Promise<HarnessRootRegistryListingForGc[]> {
|
|
48
|
+
return listHarnessRootRegistriesForGc(ctx.env);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const harnessLeasesGcAdapter: GcStoreAdapter = {
|
|
52
|
+
store: "harness_leases",
|
|
53
|
+
async collect(ctx: GcContext): Promise<GcCollectResult> {
|
|
54
|
+
const records: GcRecord[] = [];
|
|
55
|
+
const errors: GcError[] = [];
|
|
56
|
+
const registries = await collectRegistries(ctx);
|
|
57
|
+
errors.push(...registryErrors(registries).map(error => ({ ...error, store: "harness_leases" as const })));
|
|
58
|
+
|
|
59
|
+
const roots = new Set<string>();
|
|
60
|
+
for (const registry of registries) {
|
|
61
|
+
if (registry.error) continue;
|
|
62
|
+
for (const entry of registry.roots) roots.add(path.resolve(entry.root));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
for (const root of roots) {
|
|
66
|
+
const sessionsDir = path.join(root, "sessions");
|
|
67
|
+
let sessionEntries: string[];
|
|
68
|
+
try {
|
|
69
|
+
sessionEntries = await fs.readdir(sessionsDir);
|
|
70
|
+
} catch (error) {
|
|
71
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
72
|
+
if (code === "ENOENT") continue;
|
|
73
|
+
errors.push({ store: "harness_leases", scope: sessionsDir, message: errorMessage(error) });
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
for (const sessionId of sessionEntries) {
|
|
78
|
+
const sessionDir = sessionPaths(root, sessionId).dir;
|
|
79
|
+
try {
|
|
80
|
+
const stat = await fs.stat(sessionDir);
|
|
81
|
+
if (!stat.isDirectory()) continue;
|
|
82
|
+
const lease = await readLease(root, sessionId);
|
|
83
|
+
if (!lease) continue;
|
|
84
|
+
const status = classifyLeaseStatus(lease, { probe: gcProbeToLeasePidStatus(ctx.probe) });
|
|
85
|
+
const pidProbe = ctx.probe(lease.pid);
|
|
86
|
+
const pidStatus = gcPidStatusLabel(pidProbe);
|
|
87
|
+
const removable = status === "dead" && pidProbe.status === "dead";
|
|
88
|
+
records.push({
|
|
89
|
+
store: "harness_leases",
|
|
90
|
+
id: sessionId,
|
|
91
|
+
root,
|
|
92
|
+
path: sessionPaths(root, sessionId).lease,
|
|
93
|
+
pid: lease.pid,
|
|
94
|
+
pid_status: pidStatus,
|
|
95
|
+
status,
|
|
96
|
+
stale: status === "dead",
|
|
97
|
+
removable,
|
|
98
|
+
action: "none",
|
|
99
|
+
reason: removable
|
|
100
|
+
? `lease owner pid ${lease.pid} is dead`
|
|
101
|
+
: `lease owner pid ${lease.pid} is ${pidStatus}; keeping`,
|
|
102
|
+
});
|
|
103
|
+
} catch (error) {
|
|
104
|
+
errors.push({ store: "harness_leases", scope: sessionDir, message: errorMessage(error) });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return { records, errors };
|
|
110
|
+
},
|
|
111
|
+
async prune(record: GcRecord, ctx: GcContext): Promise<GcPruneOutcome> {
|
|
112
|
+
if (!record.root) return { removed: false, skipped: "missing_root" };
|
|
113
|
+
const lease = await readLease(record.root, record.id);
|
|
114
|
+
if (!lease) return { removed: false, skipped: "lease_not_dead_or_missing" };
|
|
115
|
+
const status = classifyLeaseStatus(lease, { probe: gcProbeToLeasePidStatus(ctx.probe) });
|
|
116
|
+
if (status !== "dead") return { removed: false, skipped: "lease_not_dead_or_missing" };
|
|
117
|
+
const removed = await reapDeadOwnerArtifacts(record.root, record.id, lease.ownerId, lease.leaseEpoch, {
|
|
118
|
+
probe: gcProbeToLeasePidStatus(ctx.probe),
|
|
119
|
+
});
|
|
120
|
+
return removed ? { removed: true } : { removed: false, skipped: "reaper_guard_rejected" };
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
async function splitRegistryRoots(registry: HarnessRootRegistryForGc): Promise<{
|
|
125
|
+
liveRoots: HarnessRootRegistryForGc["roots"];
|
|
126
|
+
danglingRoots: HarnessRootRegistryForGc["roots"];
|
|
127
|
+
}> {
|
|
128
|
+
const liveRoots: HarnessRootRegistryForGc["roots"] = [];
|
|
129
|
+
const danglingRoots: HarnessRootRegistryForGc["roots"] = [];
|
|
130
|
+
for (const entry of registry.roots) {
|
|
131
|
+
const sessionDir = sessionPaths(entry.root, registry.sessionId).dir;
|
|
132
|
+
if (await exists(sessionDir)) liveRoots.push(entry);
|
|
133
|
+
else danglingRoots.push(entry);
|
|
134
|
+
}
|
|
135
|
+
return { liveRoots, danglingRoots };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export const registryEntriesGcAdapter: GcStoreAdapter = {
|
|
139
|
+
store: "registry_entries",
|
|
140
|
+
async collect(ctx: GcContext): Promise<GcCollectResult> {
|
|
141
|
+
const records: GcRecord[] = [];
|
|
142
|
+
const errors: GcError[] = [];
|
|
143
|
+
const registries = await collectRegistries(ctx);
|
|
144
|
+
errors.push(...registryErrors(registries));
|
|
145
|
+
|
|
146
|
+
for (const registry of registries) {
|
|
147
|
+
if (registry.error) continue;
|
|
148
|
+
try {
|
|
149
|
+
const { liveRoots, danglingRoots } = await splitRegistryRoots(registry);
|
|
150
|
+
if (danglingRoots.length === 0) continue;
|
|
151
|
+
records.push({
|
|
152
|
+
store: "registry_entries",
|
|
153
|
+
id: registry.sessionId,
|
|
154
|
+
path: registry.file,
|
|
155
|
+
pid_status: "none",
|
|
156
|
+
status: "dangling",
|
|
157
|
+
stale: true,
|
|
158
|
+
removable: true,
|
|
159
|
+
action: "none",
|
|
160
|
+
reason: `dangling roots: ${danglingRoots.map(entry => entry.root).join(", ")}`,
|
|
161
|
+
detail: `${danglingRoots.length} dangling, ${liveRoots.length} live`,
|
|
162
|
+
});
|
|
163
|
+
} catch (error) {
|
|
164
|
+
errors.push({ store: "registry_entries", scope: registry.file, message: errorMessage(error) });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return { records, errors };
|
|
169
|
+
},
|
|
170
|
+
async prune(record: GcRecord, ctx: GcContext): Promise<GcPruneOutcome> {
|
|
171
|
+
if (!record.path) return { removed: false, skipped: "missing_registry_path" };
|
|
172
|
+
const registries = await collectRegistries(ctx);
|
|
173
|
+
const registry = registries.find(entry => entry.file === record.path);
|
|
174
|
+
if (!registry || registry.error) return { removed: false, skipped: "registry_not_readable" };
|
|
175
|
+
const { liveRoots, danglingRoots } = await splitRegistryRoots(registry);
|
|
176
|
+
if (danglingRoots.length === 0) return { removed: false, skipped: "no_dangling_roots" };
|
|
177
|
+
if (liveRoots.length === 0) {
|
|
178
|
+
await removeHarnessRootRegistryFileForGc(record.path);
|
|
179
|
+
} else {
|
|
180
|
+
await rewriteHarnessRootRegistryForGc(record.path, { sessionId: registry.sessionId, roots: liveRoots });
|
|
181
|
+
}
|
|
182
|
+
return { removed: true };
|
|
183
|
+
},
|
|
184
|
+
};
|
|
@@ -20,6 +20,7 @@ import { ControlServer, type EndpointRequest } from "./control-endpoint";
|
|
|
20
20
|
import { defaultFinalizeChecks, type FinalizeChecks, runFinalize, type ValidationCommandSpec } from "./finalize";
|
|
21
21
|
import { type OperateResult, operate } from "./operate";
|
|
22
22
|
import { preserveDirtyWorktree } from "./preserve";
|
|
23
|
+
import { RECEIPT_SPOOL_DIR_ENV, withReceiptSpoolDir } from "./receipt-spool";
|
|
23
24
|
import {
|
|
24
25
|
buildReceipt,
|
|
25
26
|
type ReceiptSubject,
|
|
@@ -28,8 +29,7 @@ import {
|
|
|
28
29
|
type VanishEvidence,
|
|
29
30
|
validateReceipt,
|
|
30
31
|
} from "./receipts";
|
|
31
|
-
import type
|
|
32
|
-
import { singleFlightAccept } from "./rpc-adapter";
|
|
32
|
+
import { type HarnessRpc, type RpcStateSnapshot, singleFlightAccept } from "./rpc-adapter";
|
|
33
33
|
import {
|
|
34
34
|
acquireLease,
|
|
35
35
|
canWriteEvents,
|
|
@@ -39,7 +39,7 @@ import {
|
|
|
39
39
|
releaseLease,
|
|
40
40
|
type SessionLease,
|
|
41
41
|
} from "./session-lease";
|
|
42
|
-
import { buildStateView, nextAllowedActions } from "./state-machine";
|
|
42
|
+
import { buildStateView, nextAllowedActions, submitUnavailableReason } from "./state-machine";
|
|
43
43
|
import {
|
|
44
44
|
appendEvent,
|
|
45
45
|
controlSocketPath,
|
|
@@ -200,11 +200,6 @@ export class RuntimeOwner {
|
|
|
200
200
|
}
|
|
201
201
|
|
|
202
202
|
async #emitMapped(mapped: NonNullable<ReturnType<typeof observeRpcOutboundFrame>>): Promise<void> {
|
|
203
|
-
await this.#emit(
|
|
204
|
-
mapped.severity,
|
|
205
|
-
mapped.kind,
|
|
206
|
-
mapped.signal ? { ...mapped.evidence, signal: mapped.signal } : mapped.evidence,
|
|
207
|
-
);
|
|
208
203
|
if (mapped.kind === "rpc_agent_completed") {
|
|
209
204
|
const state = await readSessionState(this.#opts.root, this.#opts.sessionId);
|
|
210
205
|
if (
|
|
@@ -218,6 +213,11 @@ export class RuntimeOwner {
|
|
|
218
213
|
await writeSessionState(this.#opts.root, state);
|
|
219
214
|
}
|
|
220
215
|
}
|
|
216
|
+
await this.#emit(
|
|
217
|
+
mapped.severity,
|
|
218
|
+
mapped.kind,
|
|
219
|
+
mapped.signal ? { ...mapped.evidence, signal: mapped.signal } : mapped.evidence,
|
|
220
|
+
);
|
|
221
221
|
}
|
|
222
222
|
|
|
223
223
|
#aggregateSignals(events: EventEnvelope[]): string[] {
|
|
@@ -233,6 +233,20 @@ export class RuntimeOwner {
|
|
|
233
233
|
return out;
|
|
234
234
|
}
|
|
235
235
|
|
|
236
|
+
#eventSubmitGateReason(kind: string, evidence: Record<string, unknown>): string | null {
|
|
237
|
+
const reason = typeof evidence.reason === "string" ? evidence.reason : null;
|
|
238
|
+
const signal = typeof evidence.signal === "string" ? evidence.signal : null;
|
|
239
|
+
const rpcActive =
|
|
240
|
+
kind === "prompt_accepted" ||
|
|
241
|
+
reason === "pre-state-not-idle" ||
|
|
242
|
+
kind.startsWith("rpc_") ||
|
|
243
|
+
signal === "prompt-accepted" ||
|
|
244
|
+
signal === "streaming" ||
|
|
245
|
+
signal === "tool-call" ||
|
|
246
|
+
signal === "test-running";
|
|
247
|
+
return rpcActive ? "rpc-not-idle" : null;
|
|
248
|
+
}
|
|
249
|
+
|
|
236
250
|
async #emit(severity: Severity, kind: string, evidence: Record<string, unknown>): Promise<void> {
|
|
237
251
|
const lease = await readLease(this.#opts.root, this.#opts.sessionId);
|
|
238
252
|
// Single-writer guard: only emit while we still hold a live lease.
|
|
@@ -247,6 +261,7 @@ export class RuntimeOwner {
|
|
|
247
261
|
ownerLive: true,
|
|
248
262
|
blockers: [],
|
|
249
263
|
};
|
|
264
|
+
const submitGateReason = this.#eventSubmitGateReason(kind, evidence);
|
|
250
265
|
const envelope: EventEnvelope = {
|
|
251
266
|
eventId: randomUUID(),
|
|
252
267
|
cursor: ++this.#cursor,
|
|
@@ -255,21 +270,41 @@ export class RuntimeOwner {
|
|
|
255
270
|
kind,
|
|
256
271
|
state: view,
|
|
257
272
|
evidence,
|
|
258
|
-
nextAllowedActions: nextAllowedActions(view.lifecycle, true),
|
|
273
|
+
nextAllowedActions: nextAllowedActions(view.lifecycle, true, { submitUnavailableReason: submitGateReason }),
|
|
259
274
|
writer: { ownerId: this.ownerId, leaseEpoch: this.#leaseEpoch },
|
|
260
275
|
};
|
|
261
276
|
await appendEvent(this.#opts.root, this.#opts.sessionId, envelope);
|
|
262
277
|
}
|
|
263
278
|
|
|
264
|
-
#response(
|
|
279
|
+
#response(
|
|
280
|
+
state: SessionState,
|
|
281
|
+
evidence: Record<string, unknown>,
|
|
282
|
+
ok = true,
|
|
283
|
+
submitGateReason: string | null = null,
|
|
284
|
+
): PrimitiveResponse {
|
|
265
285
|
return {
|
|
266
286
|
ok,
|
|
267
287
|
state: buildStateView(state, true),
|
|
268
288
|
evidence,
|
|
269
|
-
nextAllowedActions: nextAllowedActions(state.lifecycle, true),
|
|
289
|
+
nextAllowedActions: nextAllowedActions(state.lifecycle, true, { submitUnavailableReason: submitGateReason }),
|
|
270
290
|
};
|
|
271
291
|
}
|
|
272
292
|
|
|
293
|
+
#submitGateReason(state: SessionState, rpcState: RpcStateSnapshot | null): string | null {
|
|
294
|
+
const rpcReason = rpcState
|
|
295
|
+
? rpcState.isStreaming || rpcState.steeringQueueDepth > 0 || rpcState.followupQueueDepth > 0
|
|
296
|
+
? "rpc-not-idle"
|
|
297
|
+
: null
|
|
298
|
+
: "rpc-not-live";
|
|
299
|
+
return submitUnavailableReason(state.lifecycle, true, rpcReason);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async #withReceiptSpoolFromInput<T>(input: Record<string, unknown>, fn: () => Promise<T>): Promise<T> {
|
|
303
|
+
const requested = input[RECEIPT_SPOOL_DIR_ENV];
|
|
304
|
+
if (typeof requested === "string" && requested.trim()) return withReceiptSpoolDir(requested, fn);
|
|
305
|
+
return fn();
|
|
306
|
+
}
|
|
307
|
+
|
|
273
308
|
async #handle(req: EndpointRequest): Promise<unknown> {
|
|
274
309
|
switch (req.verb) {
|
|
275
310
|
case "ping":
|
|
@@ -281,13 +316,13 @@ export class RuntimeOwner {
|
|
|
281
316
|
case "retire":
|
|
282
317
|
return this.#retire();
|
|
283
318
|
case "finalize":
|
|
284
|
-
return this.#finalize(req.input);
|
|
319
|
+
return this.#withReceiptSpoolFromInput(req.input, () => this.#finalize(req.input));
|
|
285
320
|
case "recover":
|
|
286
|
-
return this.#recover();
|
|
321
|
+
return this.#withReceiptSpoolFromInput(req.input, () => this.#recover());
|
|
287
322
|
case "validate":
|
|
288
|
-
return this.#validate();
|
|
323
|
+
return this.#withReceiptSpoolFromInput(req.input, () => this.#validate());
|
|
289
324
|
case "operate":
|
|
290
|
-
return this.#operate(req.input);
|
|
325
|
+
return this.#withReceiptSpoolFromInput(req.input, () => this.#operate(req.input));
|
|
291
326
|
default:
|
|
292
327
|
return { ok: false, error: `owner_unsupported_verb:${req.verb}` };
|
|
293
328
|
}
|
|
@@ -297,8 +332,10 @@ export class RuntimeOwner {
|
|
|
297
332
|
const state = await this.#loadState();
|
|
298
333
|
const workspace = state.handle.workspace;
|
|
299
334
|
let streaming = false;
|
|
335
|
+
let rpcState: RpcStateSnapshot | null = null;
|
|
300
336
|
try {
|
|
301
|
-
|
|
337
|
+
rpcState = await this.#opts.rpc.getState();
|
|
338
|
+
streaming = rpcState.isStreaming;
|
|
302
339
|
} catch {
|
|
303
340
|
streaming = false;
|
|
304
341
|
}
|
|
@@ -328,12 +365,7 @@ export class RuntimeOwner {
|
|
|
328
365
|
gitDelta = "unknown";
|
|
329
366
|
}
|
|
330
367
|
}
|
|
331
|
-
const rpcLive = this.#opts.rpc.isLive
|
|
332
|
-
? this.#opts.rpc.isLive()
|
|
333
|
-
: await this.#opts.rpc
|
|
334
|
-
.getState()
|
|
335
|
-
.then(() => true)
|
|
336
|
-
.catch(() => false);
|
|
368
|
+
const rpcLive = this.#opts.rpc.isLive ? this.#opts.rpc.isLive() : rpcState !== null;
|
|
337
369
|
const rpcLastFrameAt = this.#opts.rpc.lastFrameAt ? this.#opts.rpc.lastFrameAt() : null;
|
|
338
370
|
// Sticky semantic signals come from the persisted owner event log -> survive polling gaps.
|
|
339
371
|
const recent = (await readEvents(this.#opts.root, this.#opts.sessionId, 0)).slice(-200);
|
|
@@ -343,6 +375,7 @@ export class RuntimeOwner {
|
|
|
343
375
|
(t): t is string => typeof t === "string",
|
|
344
376
|
);
|
|
345
377
|
const lastActivityAt = stamps.length > 0 ? (stamps.sort().at(-1) ?? state.updatedAt) : state.updatedAt;
|
|
378
|
+
const submitGateReason = this.#submitGateReason(state, rpcState);
|
|
346
379
|
return {
|
|
347
380
|
lifecycle: state.lifecycle,
|
|
348
381
|
ownerLive: true,
|
|
@@ -354,6 +387,8 @@ export class RuntimeOwner {
|
|
|
354
387
|
risk: deleted ? "deleted-worktree" : "normal",
|
|
355
388
|
rpcLive,
|
|
356
389
|
rpcLastFrameAt,
|
|
390
|
+
readyForSubmit: submitGateReason === null,
|
|
391
|
+
submitUnavailableReason: submitGateReason,
|
|
357
392
|
};
|
|
358
393
|
}
|
|
359
394
|
|
|
@@ -543,10 +578,21 @@ export class RuntimeOwner {
|
|
|
543
578
|
const prompt = typeof input.prompt === "string" ? input.prompt : "";
|
|
544
579
|
const state = await this.#loadState();
|
|
545
580
|
if (!prompt) {
|
|
546
|
-
return this.#response(
|
|
581
|
+
return this.#response(
|
|
582
|
+
state,
|
|
583
|
+
{ accepted: false, submitted: false, reason: "empty-prompt" },
|
|
584
|
+
false,
|
|
585
|
+
"empty-prompt",
|
|
586
|
+
);
|
|
547
587
|
}
|
|
548
|
-
|
|
549
|
-
|
|
588
|
+
const lifecycleGate = submitUnavailableReason(state.lifecycle, true);
|
|
589
|
+
if (lifecycleGate) {
|
|
590
|
+
return this.#response(
|
|
591
|
+
state,
|
|
592
|
+
{ accepted: false, submitted: false, reason: lifecycleGate },
|
|
593
|
+
false,
|
|
594
|
+
lifecycleGate,
|
|
595
|
+
);
|
|
550
596
|
}
|
|
551
597
|
const result = await singleFlightAccept(this.#opts.rpc, prompt, this.#opts.acceptanceTimeoutMs);
|
|
552
598
|
if (result.accepted) {
|
|
@@ -560,11 +606,12 @@ export class RuntimeOwner {
|
|
|
560
606
|
} else {
|
|
561
607
|
await this.#emit("warn", "prompt_not_accepted", { reason: result.reason });
|
|
562
608
|
}
|
|
609
|
+
const submitGateReason = result.accepted ? null : result.reason === "pre-state-not-idle" ? "rpc-not-idle" : null;
|
|
563
610
|
return this.#response(
|
|
564
611
|
state,
|
|
565
612
|
{
|
|
566
613
|
accepted: result.accepted,
|
|
567
|
-
submitted:
|
|
614
|
+
submitted: result.commandId !== null,
|
|
568
615
|
reason: result.reason,
|
|
569
616
|
commandId: result.commandId,
|
|
570
617
|
preSubmitCursor: result.preSubmitCursor,
|
|
@@ -572,12 +619,16 @@ export class RuntimeOwner {
|
|
|
572
619
|
acceptanceEvidence: result.preSubmitState,
|
|
573
620
|
},
|
|
574
621
|
result.accepted,
|
|
622
|
+
submitGateReason,
|
|
575
623
|
);
|
|
576
624
|
}
|
|
577
625
|
|
|
578
626
|
async #observe(): Promise<PrimitiveResponse> {
|
|
579
627
|
const state = await this.#loadState();
|
|
580
|
-
|
|
628
|
+
const observation = await this.#observeGit();
|
|
629
|
+
const submitGateReason =
|
|
630
|
+
typeof observation.submitUnavailableReason === "string" ? observation.submitUnavailableReason : null;
|
|
631
|
+
return this.#response(state, { observation, ownerRouted: true }, true, submitGateReason);
|
|
581
632
|
}
|
|
582
633
|
|
|
583
634
|
async #retire(): Promise<PrimitiveResponse> {
|
|
@@ -619,3 +670,14 @@ export async function resolveOwner(root: string, sessionId: string): Promise<Res
|
|
|
619
670
|
const live = status === "live" || status === "expiredAlive" || status === "epermAlive";
|
|
620
671
|
return { live, socketPath: lease.endpoint?.path ?? null, lease };
|
|
621
672
|
}
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* Owner liveness for verbs that do not route to the owner (e.g. `classify`): a routable owner
|
|
676
|
+
* has a live lease and a socket endpoint. This is the same lease/socket probe `observe` uses to
|
|
677
|
+
* decide routing, so non-routing verbs derive `ownerLive` consistently instead of assuming the
|
|
678
|
+
* owner is gone (which would misclassify a live owner as vanished/restart-clean).
|
|
679
|
+
*/
|
|
680
|
+
export async function resolveOwnerLive(root: string, sessionId: string): Promise<boolean> {
|
|
681
|
+
const owner = await resolveOwner(root, sessionId);
|
|
682
|
+
return owner.live && owner.socketPath !== null;
|
|
683
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import * as fs from "node:fs/promises";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { withFileLock } from "../config/file-lock";
|
|
5
|
+
import type { ReceiptEnvelope } from "./receipts";
|
|
6
|
+
|
|
7
|
+
export const RECEIPT_SPOOL_DIR_ENV = "GJC_RECEIPT_SPOOL_DIR";
|
|
8
|
+
export const RECEIPT_SPOOL_FILENAME = "spool.jsonl";
|
|
9
|
+
export const RECEIPT_SPOOL_CURSOR_WIDTH = 12;
|
|
10
|
+
|
|
11
|
+
export interface ReceiptSpoolRecord {
|
|
12
|
+
cursor: string;
|
|
13
|
+
envelope: ReceiptEnvelope<unknown>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ReceiptSpoolAppendResult {
|
|
17
|
+
cursor: string;
|
|
18
|
+
path: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const receiptSpoolDirStorage = new AsyncLocalStorage<string | undefined>();
|
|
22
|
+
const spoolQueues = new Map<string, Promise<void>>();
|
|
23
|
+
const noop = (): void => undefined;
|
|
24
|
+
export async function withReceiptSpoolDir<T>(spoolDir: string, fn: () => Promise<T>): Promise<T> {
|
|
25
|
+
const trimmed = spoolDir.trim();
|
|
26
|
+
if (!trimmed) throw new Error("receipt_spool_dir_empty");
|
|
27
|
+
const resolved = path.resolve(trimmed);
|
|
28
|
+
return receiptSpoolDirStorage.run(resolved, fn);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function resolveReceiptSpoolDir(env: NodeJS.ProcessEnv = process.env): string | undefined {
|
|
32
|
+
const active = receiptSpoolDirStorage.getStore();
|
|
33
|
+
if (active !== undefined) return active;
|
|
34
|
+
const raw = env[RECEIPT_SPOOL_DIR_ENV]?.trim();
|
|
35
|
+
return raw ? path.resolve(raw) : undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function receiptSpoolPath(spoolDir: string): string {
|
|
39
|
+
return path.join(path.resolve(spoolDir), RECEIPT_SPOOL_FILENAME);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function parseCursor(value: unknown): bigint | undefined {
|
|
43
|
+
if (typeof value !== "string" || !/^\d+$/.test(value)) return undefined;
|
|
44
|
+
try {
|
|
45
|
+
return BigInt(value);
|
|
46
|
+
} catch {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function formatReceiptSpoolCursor(cursor: bigint): string {
|
|
52
|
+
const raw = cursor.toString();
|
|
53
|
+
return raw.length >= RECEIPT_SPOOL_CURSOR_WIDTH ? raw : raw.padStart(RECEIPT_SPOOL_CURSOR_WIDTH, "0");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function readHighestReceiptSpoolCursor(spoolDir: string): Promise<bigint> {
|
|
57
|
+
const spoolFile = receiptSpoolPath(spoolDir);
|
|
58
|
+
let raw: string;
|
|
59
|
+
try {
|
|
60
|
+
raw = await fs.readFile(spoolFile, "utf8");
|
|
61
|
+
} catch (error) {
|
|
62
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return 0n;
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let highest = 0n;
|
|
67
|
+
for (const line of raw.split("\n")) {
|
|
68
|
+
const trimmed = line.trim();
|
|
69
|
+
if (!trimmed) continue;
|
|
70
|
+
try {
|
|
71
|
+
const parsed = JSON.parse(trimmed) as { cursor?: unknown };
|
|
72
|
+
const cursor = parseCursor(parsed.cursor);
|
|
73
|
+
if (cursor !== undefined && cursor > highest) highest = cursor;
|
|
74
|
+
} catch {
|
|
75
|
+
// A crash may leave a torn tail; consumers skip malformed lines and so do we.
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return highest;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function enqueueSpoolAppend<T>(spoolFile: string, task: () => Promise<T>): Promise<T> {
|
|
82
|
+
const previous = spoolQueues.get(spoolFile) ?? Promise.resolve();
|
|
83
|
+
const running = previous.catch(noop).then(task);
|
|
84
|
+
const normalized = running.then(noop, noop);
|
|
85
|
+
spoolQueues.set(spoolFile, normalized);
|
|
86
|
+
normalized
|
|
87
|
+
.finally(() => {
|
|
88
|
+
if (spoolQueues.get(spoolFile) === normalized) spoolQueues.delete(spoolFile);
|
|
89
|
+
})
|
|
90
|
+
.catch(noop);
|
|
91
|
+
return running;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function appendReceiptToSpool(
|
|
95
|
+
spoolDir: string,
|
|
96
|
+
envelope: ReceiptEnvelope<unknown>,
|
|
97
|
+
): Promise<ReceiptSpoolAppendResult> {
|
|
98
|
+
const resolvedDir = path.resolve(spoolDir);
|
|
99
|
+
const spoolFile = receiptSpoolPath(resolvedDir);
|
|
100
|
+
return enqueueSpoolAppend(spoolFile, async () => {
|
|
101
|
+
await fs.mkdir(resolvedDir, { recursive: true, mode: 0o700 });
|
|
102
|
+
return withFileLock(
|
|
103
|
+
spoolFile,
|
|
104
|
+
async () => {
|
|
105
|
+
const cursor = formatReceiptSpoolCursor((await readHighestReceiptSpoolCursor(resolvedDir)) + 1n);
|
|
106
|
+
const record: ReceiptSpoolRecord = { cursor, envelope };
|
|
107
|
+
const handle = await fs.open(spoolFile, "a", 0o600);
|
|
108
|
+
try {
|
|
109
|
+
await handle.writeFile(`${JSON.stringify(record)}\n`, "utf8");
|
|
110
|
+
await handle.sync();
|
|
111
|
+
} finally {
|
|
112
|
+
await handle.close();
|
|
113
|
+
}
|
|
114
|
+
return { cursor, path: spoolFile };
|
|
115
|
+
},
|
|
116
|
+
{ staleMs: 30_000, retries: 100, retryDelayMs: 25 },
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function appendReceiptToConfiguredSpool(
|
|
122
|
+
envelope: ReceiptEnvelope<unknown>,
|
|
123
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
124
|
+
): Promise<ReceiptSpoolAppendResult | undefined> {
|
|
125
|
+
const spoolDir = resolveReceiptSpoolDir(env);
|
|
126
|
+
if (!spoolDir) return undefined;
|
|
127
|
+
return appendReceiptToSpool(spoolDir, envelope);
|
|
128
|
+
}
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import type { HarnessLifecycle, NextAllowedAction, PrimitiveResponse, SessionState, SessionStateView } from "./types";
|
|
9
9
|
|
|
10
10
|
const TERMINAL_LIFECYCLES: ReadonlySet<HarnessLifecycle> = new Set(["completed", "retired"]);
|
|
11
|
+
const SUBMIT_READY_LIFECYCLES: ReadonlySet<HarnessLifecycle> = new Set(["started", "observing"]);
|
|
11
12
|
|
|
12
13
|
const TRANSITIONS: Record<HarnessLifecycle, readonly HarnessLifecycle[]> = {
|
|
13
14
|
new: ["started", "blocked", "retired"],
|
|
@@ -37,11 +38,32 @@ export function assertTransition(from: HarnessLifecycle, to: HarnessLifecycle):
|
|
|
37
38
|
}
|
|
38
39
|
}
|
|
39
40
|
|
|
41
|
+
export interface NextAllowedActionsOptions {
|
|
42
|
+
/** Additional live-owner/RPC readiness gate for submit, e.g. rpc-not-idle. */
|
|
43
|
+
submitUnavailableReason?: string | null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function submitUnavailableReason(
|
|
47
|
+
lifecycle: HarnessLifecycle,
|
|
48
|
+
ownerLive: boolean,
|
|
49
|
+
gateReason: string | null = null,
|
|
50
|
+
): string | null {
|
|
51
|
+
if (isTerminal(lifecycle)) return `lifecycle-terminal:${lifecycle}`;
|
|
52
|
+
if (lifecycle === "blocked") return "lifecycle-blocked";
|
|
53
|
+
if (!SUBMIT_READY_LIFECYCLES.has(lifecycle)) return `lifecycle-not-idle:${lifecycle}`;
|
|
54
|
+
if (!ownerLive) return "owner-not-live";
|
|
55
|
+
return gateReason;
|
|
56
|
+
}
|
|
57
|
+
|
|
40
58
|
/**
|
|
41
59
|
* Derive the permitted next actions for a session given its lifecycle and whether
|
|
42
60
|
* a live owner currently holds the lease.
|
|
43
61
|
*/
|
|
44
|
-
export function nextAllowedActions(
|
|
62
|
+
export function nextAllowedActions(
|
|
63
|
+
lifecycle: HarnessLifecycle,
|
|
64
|
+
ownerLive: boolean,
|
|
65
|
+
options: NextAllowedActionsOptions = {},
|
|
66
|
+
): NextAllowedAction[] {
|
|
45
67
|
const terminal = isTerminal(lifecycle);
|
|
46
68
|
const actions: NextAllowedAction[] = [];
|
|
47
69
|
const add = (verb: NextAllowedAction["verb"], available: boolean, reason?: string): void => {
|
|
@@ -57,11 +79,10 @@ export function nextAllowedActions(lifecycle: HarnessLifecycle, ownerLive: boole
|
|
|
57
79
|
// `start` creates a new session; never re-applicable to an existing record.
|
|
58
80
|
add("start", false, "session-already-exists");
|
|
59
81
|
|
|
60
|
-
// `submit` is owner-routed: it requires a live owner
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
else add("submit", true);
|
|
82
|
+
// `submit` is owner-routed: it requires a live owner, a submit-ready lifecycle,
|
|
83
|
+
// and (for owner-observed responses) an idle/routable RPC backend.
|
|
84
|
+
const submitReason = submitUnavailableReason(lifecycle, ownerLive, options.submitUnavailableReason ?? null);
|
|
85
|
+
add("submit", submitReason === null, submitReason ?? undefined);
|
|
65
86
|
|
|
66
87
|
// `recover` handles a dead/failed owner, so it is available without a live owner.
|
|
67
88
|
add("recover", !terminal, terminal ? `lifecycle-terminal:${lifecycle}` : undefined);
|