omp-multi-harness 0.1.0
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/LICENSE +21 -0
- package/README.md +351 -0
- package/package.json +76 -0
- package/scripts/cli.ts +164 -0
- package/scripts/setup/claude.ts +41 -0
- package/scripts/setup/codex.ts +35 -0
- package/scripts/setup/omp.ts +167 -0
- package/scripts/setup/toolchain.ts +81 -0
- package/scripts/setup/types.ts +76 -0
- package/scripts/setup.ts +116 -0
- package/src/agents/availability.ts +106 -0
- package/src/agents/claude-events.ts +125 -0
- package/src/agents/claude.ts +226 -0
- package/src/agents/codex-events.ts +149 -0
- package/src/agents/codex.ts +236 -0
- package/src/agents/types.ts +81 -0
- package/src/commands/agents.ts +140 -0
- package/src/commands/delegate-command.ts +159 -0
- package/src/commands/harness-setup.ts +94 -0
- package/src/commands/sessions.ts +394 -0
- package/src/config/load.ts +78 -0
- package/src/config/schema.ts +249 -0
- package/src/index.ts +129 -0
- package/src/process/executable.ts +49 -0
- package/src/process/jsonl.ts +124 -0
- package/src/process/process-error.ts +178 -0
- package/src/process/redact.ts +120 -0
- package/src/process/spawn-agent.ts +218 -0
- package/src/routing/handoff.ts +59 -0
- package/src/routing/prompt.ts +72 -0
- package/src/routing/route.ts +286 -0
- package/src/runs/lock.ts +158 -0
- package/src/runs/registry.ts +379 -0
- package/src/runs/ring-buffer.ts +81 -0
- package/src/runs/types.ts +141 -0
- package/src/sessions/resume.ts +163 -0
- package/src/sessions/store.ts +273 -0
- package/src/tools/agent-runs.ts +169 -0
- package/src/tools/ask-agent.ts +230 -0
- package/src/tools/delegate.ts +196 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session continuation policy (T-408). See _spec/08-sessions-and-parallelism.md.
|
|
3
|
+
*
|
|
4
|
+
* A pure decision module: no spawning, no registry access, no disk. The caller feeds it the
|
|
5
|
+
* stored mapping and whether a sibling run is live, and gets back the session argument to
|
|
6
|
+
* use. `runWithResume` wraps one `ExternalAgent.run` call with the single fresh-session
|
|
7
|
+
* fallback the spec allows.
|
|
8
|
+
*/
|
|
9
|
+
import type { AgentName, AgentRequest, AgentResult, AgentRunOptions, ExternalAgent } from "../agents/types.ts";
|
|
10
|
+
import { AgentError, looksLikeAuthFailure } from "../process/process-error.ts";
|
|
11
|
+
import { buildHandoff } from "../routing/handoff.ts";
|
|
12
|
+
|
|
13
|
+
export interface ResumeContext {
|
|
14
|
+
agent: AgentName;
|
|
15
|
+
/** `continueSession` from the request. Default true per spec 08. */
|
|
16
|
+
continueSession?: boolean;
|
|
17
|
+
/** Worker session id mapped to `(ompSession, cwd)`, from the session store. */
|
|
18
|
+
storedSessionId?: string;
|
|
19
|
+
/** True when another run for the SAME agent in the SAME cwd is already live. */
|
|
20
|
+
siblingActive?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type ResumeReason =
|
|
24
|
+
/** `continueSession: false` — the caller asked for a clean slate. */
|
|
25
|
+
| "fresh-requested"
|
|
26
|
+
/** Nothing mapped for this (OMP session, repo) yet. */
|
|
27
|
+
| "no-mapping"
|
|
28
|
+
/** Normal continuation of the mapped worker session. */
|
|
29
|
+
| "resume"
|
|
30
|
+
/** Claude: branch off the mapped session so both runs can proceed. */
|
|
31
|
+
| "forked"
|
|
32
|
+
/** Codex: no fork flag exists, so the second parallel run starts clean. */
|
|
33
|
+
| "fresh-parallel";
|
|
34
|
+
|
|
35
|
+
export interface SessionDecision {
|
|
36
|
+
/** Put on `AgentRequest.sessionId`. Undefined means "start a fresh worker session". */
|
|
37
|
+
sessionId?: string;
|
|
38
|
+
/**
|
|
39
|
+
* Claude only: pass as `fork` to `buildClaudeArgs` so argv gets `--fork-session`.
|
|
40
|
+
* `ExternalAgent.run` has no fork field, so the wiring layer must forward this.
|
|
41
|
+
*/
|
|
42
|
+
fork: boolean;
|
|
43
|
+
reason: ResumeReason;
|
|
44
|
+
/** Merged into `AgentResult.metadata` by `runWithResume`. */
|
|
45
|
+
metadata: { forked?: true };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Decide the session argument for a run. Two parallel runs on the same agent in the same
|
|
50
|
+
* cwd must never resume the same worker session — the second forks (Claude) or starts
|
|
51
|
+
* fresh (Codex), and either way is marked `forked`.
|
|
52
|
+
*/
|
|
53
|
+
export function decideSession(ctx: ResumeContext): SessionDecision {
|
|
54
|
+
if (ctx.continueSession === false) return { fork: false, reason: "fresh-requested", metadata: {} };
|
|
55
|
+
if (!ctx.storedSessionId) return { fork: false, reason: "no-mapping", metadata: {} };
|
|
56
|
+
if (ctx.siblingActive) {
|
|
57
|
+
return ctx.agent === "claude"
|
|
58
|
+
? { sessionId: ctx.storedSessionId, fork: true, reason: "forked", metadata: { forked: true } }
|
|
59
|
+
: { fork: false, reason: "fresh-parallel", metadata: { forked: true } };
|
|
60
|
+
}
|
|
61
|
+
return { sessionId: ctx.storedSessionId, fork: false, reason: "resume", metadata: {} };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Failures that mean "that worker session is gone", not "the task failed". */
|
|
65
|
+
const RESUME_FAILURE_PATTERNS = [
|
|
66
|
+
/session (id )?[^\s]* ?(was )?not found/i,
|
|
67
|
+
/no (such )?session/i,
|
|
68
|
+
/no (session|conversation|thread) found/i,
|
|
69
|
+
/unknown session/i,
|
|
70
|
+
/session .*(does not|doesn't) exist/i,
|
|
71
|
+
/could not (find|resume|load) (the )?(session|conversation|thread)/i,
|
|
72
|
+
/failed to resume/i,
|
|
73
|
+
/resume(d)? session .*(failed|invalid)/i,
|
|
74
|
+
/(conversation|thread) .*not found/i,
|
|
75
|
+
/invalid session id/i,
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
/** Codes that can never be cured by starting a fresh session — never retry on these. */
|
|
79
|
+
const NEVER_RESUME_FALLBACK = new Set(["AUTH_REQUIRED", "PROVIDER_LIMIT", "EXECUTABLE_NOT_FOUND", "CANCELLED", "TIMEOUT", "AGENT_DISABLED", "INVALID_CWD", "WORKSPACE_BUSY"]);
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Classify a failure as "the resume failed" vs. a genuine failure. Deliberately
|
|
83
|
+
* conservative: only the explicit code, or a process failure whose stderr names a missing
|
|
84
|
+
* session, qualifies. An auth or provider-limit failure must never trigger a retry.
|
|
85
|
+
*/
|
|
86
|
+
export function isResumeFailure(error: unknown): boolean {
|
|
87
|
+
if (!(error instanceof AgentError)) return false;
|
|
88
|
+
if (error.code === "SESSION_RESUME_FAILED") return true;
|
|
89
|
+
if (NEVER_RESUME_FALLBACK.has(error.code)) return false;
|
|
90
|
+
if (error.code !== "PROCESS_FAILED" && error.code !== "INVALID_OUTPUT") return false;
|
|
91
|
+
const text = `${error.stderrTail ?? ""}\n${error.message}`;
|
|
92
|
+
if (looksLikeAuthFailure(text)) return false;
|
|
93
|
+
return RESUME_FAILURE_PATTERNS.some((p) => p.test(text));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const FRESH_SESSION_PREAMBLE =
|
|
97
|
+
"The earlier session for this repository could not be resumed, so this is a fresh session with no prior history. " +
|
|
98
|
+
"Re-read whatever you need from the repository rather than assuming earlier context.";
|
|
99
|
+
|
|
100
|
+
/** The note appended to the result so the user knows history was lost. */
|
|
101
|
+
export function resumeFallbackNote(agent: AgentName): string {
|
|
102
|
+
return `Note: the previous ${agent} session could not be resumed. This ran in a fresh session with a compact handoff instead of full history.`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Task text for the retry: the compact handoff block, never an emulation of the transcript.
|
|
107
|
+
*/
|
|
108
|
+
export function buildResumeFallbackTask(request: AgentRequest, maxHandoffChars: number): string {
|
|
109
|
+
const context = [request.context?.trim(), FRESH_SESSION_PREAMBLE].filter(Boolean).join("\n\n");
|
|
110
|
+
return buildHandoff({ task: request.task, mode: request.mode, context, maxChars: maxHandoffChars });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface ResumeDeps {
|
|
114
|
+
decision: SessionDecision;
|
|
115
|
+
/** Cap for the handoff carried into the fallback run. Defaults to spec's 4 000. */
|
|
116
|
+
maxHandoffChars?: number;
|
|
117
|
+
/**
|
|
118
|
+
* Runner override. Needed for Claude, whose `--fork-session` argument is not expressible
|
|
119
|
+
* on `AgentRequest`. Defaults to `agent.run`.
|
|
120
|
+
*/
|
|
121
|
+
run?: (request: AgentRequest, options: AgentRunOptions) => Promise<AgentResult>;
|
|
122
|
+
/** Called once with the user-visible note when the fallback fires. */
|
|
123
|
+
onNote?: (note: string) => void;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Run `agent` with the decided session, falling back to a fresh session **exactly once**
|
|
128
|
+
* when — and only when — the resume itself failed. The fallback carries the compact
|
|
129
|
+
* handoff, sets `metadata.resumedFallback`, and appends a visible note to the output.
|
|
130
|
+
*/
|
|
131
|
+
export async function runWithResume(
|
|
132
|
+
agent: ExternalAgent,
|
|
133
|
+
request: AgentRequest,
|
|
134
|
+
options: AgentRunOptions,
|
|
135
|
+
deps: ResumeDeps,
|
|
136
|
+
): Promise<AgentResult> {
|
|
137
|
+
const { decision } = deps;
|
|
138
|
+
const run = deps.run ?? ((req, opts) => agent.run(req, opts));
|
|
139
|
+
const first: AgentRequest = { ...request, sessionId: decision.sessionId };
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
return withMetadata(await run(first, options), decision.metadata);
|
|
143
|
+
} catch (e) {
|
|
144
|
+
// Only a failed *resume* is recoverable, and only when we actually tried to resume.
|
|
145
|
+
if (!decision.sessionId || !isResumeFailure(e)) throw e;
|
|
146
|
+
|
|
147
|
+
const note = resumeFallbackNote(agent.name);
|
|
148
|
+
deps.onNote?.(note);
|
|
149
|
+
const retry: AgentRequest = {
|
|
150
|
+
...request,
|
|
151
|
+
sessionId: undefined,
|
|
152
|
+
context: undefined,
|
|
153
|
+
task: buildResumeFallbackTask(request, deps.maxHandoffChars ?? 4_000),
|
|
154
|
+
};
|
|
155
|
+
const result = withMetadata(await run(retry, options), { ...decision.metadata, resumedFallback: true });
|
|
156
|
+
return { ...result, output: `${result.output}\n\n${note}` };
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function withMetadata(result: AgentResult, extra: Record<string, unknown>): AgentResult {
|
|
161
|
+
if (Object.keys(extra).length === 0) return result;
|
|
162
|
+
return { ...result, metadata: { ...result.metadata, ...extra } };
|
|
163
|
+
}
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OMP ↔ worker session mapping (T-407). See _spec/08-sessions-and-parallelism.md.
|
|
3
|
+
*
|
|
4
|
+
* One JSON file per `(ompSessionId, realpath(cwd))` pair under the **runtime-resolved**
|
|
5
|
+
* agent dir, so `continueSession: true` survives an OMP restart. Only ids, the cwd and
|
|
6
|
+
* timestamps are persisted — never task text, tokens, keys, env or cookies.
|
|
7
|
+
*/
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
|
+
import { chmodSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
import { join, resolve } from "node:path";
|
|
12
|
+
import type { AgentName } from "../agents/types.ts";
|
|
13
|
+
|
|
14
|
+
/** A worker session id plus when we last saw it. */
|
|
15
|
+
export interface WorkerSessionRef {
|
|
16
|
+
sessionId: string;
|
|
17
|
+
/** ISO 8601. */
|
|
18
|
+
updatedAt: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The persisted record. Anything not on this shape must never reach disk. */
|
|
22
|
+
export interface HarnessSession {
|
|
23
|
+
version: 1;
|
|
24
|
+
ompSessionId: string;
|
|
25
|
+
cwd: string;
|
|
26
|
+
workers: {
|
|
27
|
+
codex?: WorkerSessionRef;
|
|
28
|
+
claude?: WorkerSessionRef;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface SessionStoreOptions {
|
|
33
|
+
/**
|
|
34
|
+
* Root to store under, in place of the resolved agent dir. Tests pass a temp dir;
|
|
35
|
+
* production leaves it unset so `--profile` / `PI_CODING_AGENT_DIR` are honored.
|
|
36
|
+
*/
|
|
37
|
+
baseDir?: string;
|
|
38
|
+
/** Non-fatal problems (corrupt file, rejected id, unwritable dir) land here. */
|
|
39
|
+
onWarning?: (message: string) => void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface SessionStore {
|
|
43
|
+
/** Absolute path of the file backing this key. Resolves the agent dir on first use. */
|
|
44
|
+
path(ompSessionId: string, cwd: string): Promise<string>;
|
|
45
|
+
/** The mapping, or undefined when there is none — including when the file is corrupt. */
|
|
46
|
+
get(ompSessionId: string, cwd: string): Promise<HarnessSession | undefined>;
|
|
47
|
+
/** Convenience: just the worker session id for one agent. */
|
|
48
|
+
workerSessionId(ompSessionId: string, cwd: string, agent: AgentName): Promise<string | undefined>;
|
|
49
|
+
/** Upsert one agent's worker session id. Returns the record as persisted. */
|
|
50
|
+
record(ompSessionId: string, cwd: string, agent: AgentName, workerSessionId: string): Promise<HarnessSession | undefined>;
|
|
51
|
+
/** Drop one agent's mapping, or the whole record when `agent` is omitted. */
|
|
52
|
+
clear(ompSessionId: string, cwd: string, agent?: AgentName): Promise<boolean>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Worker session ids are UUIDs (Claude) or opaque thread ids (Codex) — nothing else. */
|
|
56
|
+
const SESSION_ID = /^[A-Za-z0-9._:-]{1,128}$/;
|
|
57
|
+
/** OMP session ids are opaque; cap them so a hostile id cannot bloat the file. */
|
|
58
|
+
const OMP_SESSION_ID = /^[\x20-\x7e]{1,256}$/;
|
|
59
|
+
|
|
60
|
+
/** `<agentDir>/multi-harness/sessions`. */
|
|
61
|
+
const SUBDIR = join("multi-harness", "sessions");
|
|
62
|
+
|
|
63
|
+
let cachedAgentDir: Promise<string> | undefined;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The active agent dir, resolved at runtime so `--profile` and `PI_CODING_AGENT_DIR` are
|
|
67
|
+
* honored. Prefers OMP's own `getAgentDir` (it knows about profiles); falls back to the env
|
|
68
|
+
* var, then `~/.omp/agent`. Never hard-code the last one at a call site.
|
|
69
|
+
*/
|
|
70
|
+
export async function resolveAgentDir(): Promise<string> {
|
|
71
|
+
cachedAgentDir ??= (async () => {
|
|
72
|
+
// PI_CODING_AGENT_DIR is checked FIRST, before OMP's own resolver, for one concrete
|
|
73
|
+
// reason: upstream `getAgentDir()` reads the env var once at module load and memoizes
|
|
74
|
+
// it forever. Set before the process starts (the production case) both orders agree.
|
|
75
|
+
// Changed afterwards, the upstream value is frozen and no reset seam of ours can undo
|
|
76
|
+
// it — so consulting the env directly is what makes this resolver actually honor the
|
|
77
|
+
// variable, rather than only appearing to. Env beats --profile in OMP too, so the
|
|
78
|
+
// precedence is unchanged; when the var is absent we still defer to OMP for profiles.
|
|
79
|
+
const fromEnv = process.env.PI_CODING_AGENT_DIR;
|
|
80
|
+
if (fromEnv && fromEnv.length > 0) return resolve(fromEnv);
|
|
81
|
+
|
|
82
|
+
// The coding-agent index is the canonical re-export and is already loaded inside OMP;
|
|
83
|
+
// `pi-utils/dirs` is the same resolver without pulling the whole agent in, which
|
|
84
|
+
// matters for tests and scripts where the native addon may be missing.
|
|
85
|
+
for (const specifier of ["@oh-my-pi/pi-coding-agent", "@oh-my-pi/pi-utils/dirs"]) {
|
|
86
|
+
try {
|
|
87
|
+
const mod = (await import(specifier)) as { getAgentDir?: () => string };
|
|
88
|
+
const dir = mod.getAgentDir?.();
|
|
89
|
+
if (typeof dir === "string" && dir.length > 0) return dir;
|
|
90
|
+
} catch {
|
|
91
|
+
// Not importable here — try the next source.
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return join(homedir(), ".omp", "agent");
|
|
95
|
+
})();
|
|
96
|
+
return cachedAgentDir;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Test seam: forget the memoized agent dir so a changed env is picked up again. */
|
|
100
|
+
export function resetAgentDirCache(): void {
|
|
101
|
+
cachedAgentDir = undefined;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Canonical cwd for keying. Symlinked and relative paths must land on the same key as the
|
|
106
|
+
* real path, or two runs in one repo would get two mappings.
|
|
107
|
+
*/
|
|
108
|
+
export function normalizeCwd(cwd: string): string {
|
|
109
|
+
const absolute = resolve(cwd);
|
|
110
|
+
try {
|
|
111
|
+
return realpathSync(absolute);
|
|
112
|
+
} catch {
|
|
113
|
+
// Not on disk (yet) — the resolved path is still a stable key.
|
|
114
|
+
return absolute;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Stable, non-reversible file name for a key. The id and path never appear in it. */
|
|
119
|
+
export function sessionKey(ompSessionId: string, cwd: string): string {
|
|
120
|
+
return createHash("sha256").update(`${ompSessionId}\0${normalizeCwd(cwd)}`).digest("hex").slice(0, 32);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Only the shape above survives a round trip; anything else is treated as corrupt. */
|
|
124
|
+
function parseSession(raw: unknown): HarnessSession | undefined {
|
|
125
|
+
if (typeof raw !== "object" || raw === null) return undefined;
|
|
126
|
+
const value = raw as Record<string, unknown>;
|
|
127
|
+
if (value.version !== 1) return undefined;
|
|
128
|
+
if (typeof value.ompSessionId !== "string" || typeof value.cwd !== "string") return undefined;
|
|
129
|
+
const workers: HarnessSession["workers"] = {};
|
|
130
|
+
const rawWorkers = typeof value.workers === "object" && value.workers !== null ? (value.workers as Record<string, unknown>) : {};
|
|
131
|
+
for (const agent of ["codex", "claude"] as const) {
|
|
132
|
+
const entry = rawWorkers[agent];
|
|
133
|
+
if (typeof entry !== "object" || entry === null) continue;
|
|
134
|
+
const { sessionId, updatedAt } = entry as Record<string, unknown>;
|
|
135
|
+
if (typeof sessionId !== "string" || !SESSION_ID.test(sessionId)) continue;
|
|
136
|
+
workers[agent] = { sessionId, updatedAt: typeof updatedAt === "string" ? updatedAt : new Date(0).toISOString() };
|
|
137
|
+
}
|
|
138
|
+
return { version: 1, ompSessionId: value.ompSessionId, cwd: value.cwd, workers };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Write via temp + rename so a crash mid-write can never leave a half file in place. */
|
|
142
|
+
function writeAtomic(file: string, body: string): void {
|
|
143
|
+
const tmp = `${file}.${process.pid.toString(36)}${Math.random().toString(36).slice(2, 8)}.tmp`;
|
|
144
|
+
try {
|
|
145
|
+
writeFileSync(tmp, body, { mode: 0o600 });
|
|
146
|
+
renameSync(tmp, file);
|
|
147
|
+
// rename keeps the temp file's mode, but an existing target could predate this code.
|
|
148
|
+
chmodSync(file, 0o600);
|
|
149
|
+
} catch (e) {
|
|
150
|
+
try {
|
|
151
|
+
rmSync(tmp, { force: true });
|
|
152
|
+
} catch {
|
|
153
|
+
// best effort
|
|
154
|
+
}
|
|
155
|
+
throw e;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Create a session store. All methods are async because the agent dir is resolved lazily;
|
|
161
|
+
* none of them ever throw — I/O problems degrade to "no mapping" plus a warning, because a
|
|
162
|
+
* missing resume is an inconvenience while a thrown error would kill the run.
|
|
163
|
+
*/
|
|
164
|
+
export function createSessionStore(options: SessionStoreOptions = {}): SessionStore {
|
|
165
|
+
const warn = options.onWarning ?? (() => {});
|
|
166
|
+
|
|
167
|
+
async function dir(): Promise<string> {
|
|
168
|
+
const base = options.baseDir ?? (await resolveAgentDir());
|
|
169
|
+
const target = join(base, SUBDIR);
|
|
170
|
+
mkdirSync(target, { recursive: true, mode: 0o700 });
|
|
171
|
+
// mkdir's mode is masked by umask and skipped for existing dirs; be explicit.
|
|
172
|
+
chmodSync(target, 0o700);
|
|
173
|
+
chmodSync(join(base, "multi-harness"), 0o700);
|
|
174
|
+
return target;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function path(ompSessionId: string, cwd: string): Promise<string> {
|
|
178
|
+
return join(await dir(), `${sessionKey(ompSessionId, cwd)}.json`);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function read(ompSessionId: string, cwd: string): Promise<HarnessSession | undefined> {
|
|
182
|
+
let file: string;
|
|
183
|
+
try {
|
|
184
|
+
file = await path(ompSessionId, cwd);
|
|
185
|
+
} catch (e) {
|
|
186
|
+
warn(`multi-harness: session store unavailable (${(e as Error).message}) — continuing without session mapping`);
|
|
187
|
+
return undefined;
|
|
188
|
+
}
|
|
189
|
+
let body: string;
|
|
190
|
+
try {
|
|
191
|
+
body = readFileSync(file, "utf8");
|
|
192
|
+
} catch (e) {
|
|
193
|
+
const code = (e as NodeJS.ErrnoException).code;
|
|
194
|
+
if (code !== "ENOENT") warn(`multi-harness: could not read ${file} (${code ?? (e as Error).message}) — treating as no mapping`);
|
|
195
|
+
return undefined;
|
|
196
|
+
}
|
|
197
|
+
try {
|
|
198
|
+
const parsed = parseSession(JSON.parse(body));
|
|
199
|
+
if (!parsed) {
|
|
200
|
+
warn(`multi-harness: ${file} is not a v1 session record — treating as no mapping`);
|
|
201
|
+
return undefined;
|
|
202
|
+
}
|
|
203
|
+
return parsed;
|
|
204
|
+
} catch {
|
|
205
|
+
warn(`multi-harness: ${file} is corrupt JSON — treating as no mapping`);
|
|
206
|
+
return undefined;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
path,
|
|
212
|
+
get: read,
|
|
213
|
+
|
|
214
|
+
async workerSessionId(ompSessionId, cwd, agent) {
|
|
215
|
+
return (await read(ompSessionId, cwd))?.workers[agent]?.sessionId;
|
|
216
|
+
},
|
|
217
|
+
|
|
218
|
+
async record(ompSessionId, cwd, agent, workerSessionId) {
|
|
219
|
+
if (!SESSION_ID.test(workerSessionId)) {
|
|
220
|
+
warn(`multi-harness: refusing to persist a malformed ${agent} session id — not saved`);
|
|
221
|
+
return undefined;
|
|
222
|
+
}
|
|
223
|
+
if (!OMP_SESSION_ID.test(ompSessionId)) {
|
|
224
|
+
warn("multi-harness: refusing to persist a malformed OMP session id — not saved");
|
|
225
|
+
return undefined;
|
|
226
|
+
}
|
|
227
|
+
const existing = await read(ompSessionId, cwd);
|
|
228
|
+
// Built field by field on purpose: only ids, cwd and timestamps may reach disk.
|
|
229
|
+
const next: HarnessSession = {
|
|
230
|
+
version: 1,
|
|
231
|
+
ompSessionId,
|
|
232
|
+
cwd: normalizeCwd(cwd),
|
|
233
|
+
workers: { ...existing?.workers, [agent]: { sessionId: workerSessionId, updatedAt: new Date().toISOString() } },
|
|
234
|
+
};
|
|
235
|
+
try {
|
|
236
|
+
writeAtomic(await path(ompSessionId, cwd), `${JSON.stringify(next, null, "\t")}\n`);
|
|
237
|
+
} catch (e) {
|
|
238
|
+
warn(`multi-harness: could not persist the session mapping (${(e as Error).message}) — resume will not survive a restart`);
|
|
239
|
+
return undefined;
|
|
240
|
+
}
|
|
241
|
+
return next;
|
|
242
|
+
},
|
|
243
|
+
|
|
244
|
+
async clear(ompSessionId, cwd, agent) {
|
|
245
|
+
let file: string;
|
|
246
|
+
try {
|
|
247
|
+
file = await path(ompSessionId, cwd);
|
|
248
|
+
} catch {
|
|
249
|
+
return false;
|
|
250
|
+
}
|
|
251
|
+
if (agent) {
|
|
252
|
+
const existing = await read(ompSessionId, cwd);
|
|
253
|
+
if (!existing?.workers[agent]) return false;
|
|
254
|
+
const workers = { ...existing.workers };
|
|
255
|
+
delete workers[agent];
|
|
256
|
+
try {
|
|
257
|
+
writeAtomic(file, `${JSON.stringify({ ...existing, workers }, null, "\t")}\n`);
|
|
258
|
+
return true;
|
|
259
|
+
} catch (e) {
|
|
260
|
+
warn(`multi-harness: could not clear the ${agent} session mapping (${(e as Error).message})`);
|
|
261
|
+
return false;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
try {
|
|
265
|
+
rmSync(file, { force: true });
|
|
266
|
+
return true;
|
|
267
|
+
} catch (e) {
|
|
268
|
+
warn(`multi-harness: could not remove ${file} (${(e as Error).message})`);
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
},
|
|
272
|
+
};
|
|
273
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `agent_runs` (T-502) — the supervisor's handle on background work: fan out several runs,
|
|
3
|
+
* then join them, without the user having to drive `/sessions` (_spec/06-tools.md).
|
|
4
|
+
*
|
|
5
|
+
* Every answer is compact, model-readable text. Unknown ids and already-finished cancels are
|
|
6
|
+
* ordinary results, not errors — the supervisor should be able to poll without exception
|
|
7
|
+
* handling.
|
|
8
|
+
*/
|
|
9
|
+
import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
|
|
10
|
+
import type { MultiHarnessConfig } from "../config/schema.ts";
|
|
11
|
+
import { truncateMiddle } from "../routing/handoff.ts";
|
|
12
|
+
import { isTerminal, type RunRegistry, type RunView } from "../runs/types.ts";
|
|
13
|
+
|
|
14
|
+
const ACTIONS = ["list", "status", "result", "cancel", "wait"] as const;
|
|
15
|
+
const DEFAULT_WAIT_MS = 60_000;
|
|
16
|
+
|
|
17
|
+
const DESCRIPTION =
|
|
18
|
+
"Manage delegated agent runs started with background: true. " +
|
|
19
|
+
"list = every run in this session with status and elapsed time; status = one run without its output; " +
|
|
20
|
+
"result = the finished output of one run; cancel = stop a run; wait = block until a run finishes or waitMs elapses. " +
|
|
21
|
+
"Fan out independent work with background runs, then join it here.";
|
|
22
|
+
|
|
23
|
+
/** What the schema below accepts — see the cast note in `ask-agent.ts`. */
|
|
24
|
+
interface AgentRunsParams {
|
|
25
|
+
action: (typeof ACTIONS)[number];
|
|
26
|
+
runId?: string;
|
|
27
|
+
waitMs?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function elapsed(view: RunView): string {
|
|
31
|
+
return `${(view.elapsedMs / 1000).toFixed(1)}s`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** One line for `list`: everything needed to decide what to do next, nothing more. */
|
|
35
|
+
export function renderRunLine(view: RunView): string {
|
|
36
|
+
const bits = [view.id, view.agent];
|
|
37
|
+
if (view.mode) bits.push(view.mode);
|
|
38
|
+
bits.push(view.status, elapsed(view));
|
|
39
|
+
if (!isTerminal(view.status) && view.phase) bits.push(view.phase);
|
|
40
|
+
return `${bits.join(" · ")} — ${view.summary}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Full rendering of one run, with its output truncated from the middle at
|
|
45
|
+
* `limits.maxOutputChars`. Shared with `delegate` so a foreground call and a joined
|
|
46
|
+
* background run read identically.
|
|
47
|
+
*/
|
|
48
|
+
export function renderRunView(view: RunView, maxOutputChars: number): { text: string; truncated: boolean } {
|
|
49
|
+
const header = [view.agent, view.status, elapsed(view)];
|
|
50
|
+
if (view.mode) header.splice(1, 0, view.mode);
|
|
51
|
+
if (view.workerSessionId) header.push(`session ${view.workerSessionId.slice(0, 8)}`);
|
|
52
|
+
if (view.readOnly) header.push("read-only");
|
|
53
|
+
|
|
54
|
+
if (view.errorCode) {
|
|
55
|
+
return { text: `[${header.join(" · ")}]\n${view.errorCode}: ${view.errorMessage ?? "failed"}`, truncated: false };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const body = truncateMiddle(view.output ?? "(no output)", maxOutputChars);
|
|
59
|
+
return { text: `[${header.join(" · ")}]\n${body.text}`, truncated: body.truncated };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface AgentRunsDeps {
|
|
63
|
+
pi: ExtensionAPI;
|
|
64
|
+
getConfig: () => MultiHarnessConfig;
|
|
65
|
+
/** Taken as a getter so this module never imports the registry implementation. */
|
|
66
|
+
getRegistry: () => RunRegistry;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Details are free-form state carried back to the supervisor, so one shape for every
|
|
70
|
+
* branch keeps the tool's inferred result type uniform. */
|
|
71
|
+
type Details = Record<string, unknown>;
|
|
72
|
+
|
|
73
|
+
function reply(body: string, details?: Details, isError?: boolean) {
|
|
74
|
+
return { content: [{ type: "text" as const, text: body }], details, isError };
|
|
75
|
+
}
|
|
76
|
+
const missing = (action: string) => reply(`runId is required for action "${action}".`);
|
|
77
|
+
const unknown = (runId: string) => reply(`No run with id ${runId}. Use action "list" to see current runs.`);
|
|
78
|
+
|
|
79
|
+
export function registerAgentRunsTool({ pi, getConfig, getRegistry }: AgentRunsDeps): void {
|
|
80
|
+
const z = pi.zod;
|
|
81
|
+
|
|
82
|
+
pi.registerTool({
|
|
83
|
+
name: "agent_runs",
|
|
84
|
+
label: "Agent Runs",
|
|
85
|
+
description: DESCRIPTION,
|
|
86
|
+
// Read-only over the registry; `cancel` stops a child this extension already owns.
|
|
87
|
+
approval: "read",
|
|
88
|
+
parameters: z.object({
|
|
89
|
+
action: z.enum(ACTIONS).describe("list | status | result | cancel | wait"),
|
|
90
|
+
runId: z.string().optional().describe("Required for status, result, cancel, and wait."),
|
|
91
|
+
waitMs: z.number().optional().describe("Cap for action \"wait\". Default 60000."),
|
|
92
|
+
}),
|
|
93
|
+
async execute(_toolCallId, rawParams) {
|
|
94
|
+
const params = rawParams as AgentRunsParams;
|
|
95
|
+
const config = getConfig();
|
|
96
|
+
const registry = getRegistry();
|
|
97
|
+
|
|
98
|
+
if (params.action === "list") {
|
|
99
|
+
const runs = registry.list();
|
|
100
|
+
if (runs.length === 0) return reply("No agent runs in this session.");
|
|
101
|
+
return reply(runs.map(renderRunLine).join("\n"), { count: runs.length, runIds: runs.map((r) => r.id) });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const runId = params.runId?.trim();
|
|
105
|
+
if (!runId) return missing(params.action);
|
|
106
|
+
|
|
107
|
+
if (params.action === "cancel") {
|
|
108
|
+
const before = registry.get(runId);
|
|
109
|
+
if (!before) return unknown(runId);
|
|
110
|
+
// Idempotent by contract: cancelling a terminal run reports, never throws.
|
|
111
|
+
const cancelled = await registry.cancel(runId);
|
|
112
|
+
const after = registry.get(runId) ?? before;
|
|
113
|
+
return reply(
|
|
114
|
+
cancelled ? `Cancelled ${runId} (${after.agent}).` : `${runId} was already ${after.status}; nothing to cancel.`,
|
|
115
|
+
{ runId, cancelled, status: after.status },
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (params.action === "wait") {
|
|
120
|
+
const waitMs = params.waitMs && params.waitMs > 0 ? params.waitMs : DEFAULT_WAIT_MS;
|
|
121
|
+
const view = await registry.wait(runId, waitMs);
|
|
122
|
+
if (!view) return unknown(runId);
|
|
123
|
+
if (!isTerminal(view.status)) {
|
|
124
|
+
return reply(
|
|
125
|
+
`${runId} is still ${view.status} after ${(waitMs / 1000).toFixed(0)}s — ${view.phase || "no phase"}. Wait again or check status.`,
|
|
126
|
+
{ runId, status: view.status, timedOut: true },
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
const rendered = renderRunView(view, config.limits.maxOutputChars);
|
|
130
|
+
return reply(rendered.text, {
|
|
131
|
+
runId,
|
|
132
|
+
agent: view.agent,
|
|
133
|
+
status: view.status,
|
|
134
|
+
durationMs: view.elapsedMs,
|
|
135
|
+
truncated: rendered.truncated,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const view = registry.get(runId);
|
|
140
|
+
if (!view) return unknown(runId);
|
|
141
|
+
|
|
142
|
+
if (params.action === "status") {
|
|
143
|
+
const bits = [view.agent, view.status, elapsed(view)];
|
|
144
|
+
if (view.phase) bits.push(view.phase);
|
|
145
|
+
return reply(`${runId} · ${bits.join(" · ")} — ${view.summary}`, {
|
|
146
|
+
runId,
|
|
147
|
+
agent: view.agent,
|
|
148
|
+
status: view.status,
|
|
149
|
+
phase: view.phase,
|
|
150
|
+
elapsedMs: view.elapsedMs,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// result
|
|
155
|
+
if (!isTerminal(view.status)) {
|
|
156
|
+
return reply(`${runId} is still ${view.status} (${elapsed(view)}) — ${view.phase || "no phase"}. Use action "wait" to join it.`, {
|
|
157
|
+
runId,
|
|
158
|
+
status: view.status,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
const rendered = renderRunView(view, config.limits.maxOutputChars);
|
|
162
|
+
return reply(
|
|
163
|
+
rendered.text,
|
|
164
|
+
{ runId, agent: view.agent, status: view.status, durationMs: view.elapsedMs, truncated: rendered.truncated },
|
|
165
|
+
view.status === "failed",
|
|
166
|
+
);
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
}
|