@ravefox/space 0.0.0-stage → 0.2.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 +203 -0
- package/NOTICE +44 -0
- package/README.md +49 -2
- package/assets/webhook-sdk/python/bot.py +63 -0
- package/assets/webhook-sdk/python/commonly.py +232 -0
- package/node_modules/@commonlyai/seats/README.md +25 -0
- package/node_modules/@commonlyai/seats/package.json +22 -0
- package/node_modules/@commonlyai/seats/src/adapters/hermes.js +264 -0
- package/node_modules/@commonlyai/seats/src/adapters/stub.js +24 -0
- package/node_modules/@commonlyai/seats/src/contract.js +48 -0
- package/node_modules/@commonlyai/seats/src/failures.js +44 -0
- package/node_modules/@commonlyai/seats/src/harness-plan.js +329 -0
- package/node_modules/@commonlyai/seats/src/index.js +22 -0
- package/node_modules/@commonlyai/seats/src/process-group.js +269 -0
- package/node_modules/@commonlyai/seats/src/record/format.js +737 -0
- package/node_modules/@commonlyai/seats/src/record/otel.js +238 -0
- package/node_modules/@commonlyai/seats/src/runtime-observation.js +29 -0
- package/node_modules/@commonlyai/seats/src/seat-model.js +17 -0
- package/node_modules/@commonlyai/seats/src/supervisor.js +308 -0
- package/node_modules/@commonlyai/seats/src/trace/events.js +46 -0
- package/node_modules/@commonlyai/seats/src/trace/store.js +276 -0
- package/node_modules/@commonlyai/seats/src/turn-processes.js +226 -0
- package/node_modules/@commonlyai/seats/src/upstream-refusal.js +418 -0
- package/node_modules/@commonlyai/seats/src/valet/env.js +201 -0
- package/node_modules/@commonlyai/seats/src/valet/grant.js +123 -0
- package/node_modules/@commonlyai/seats/src/valet/permissions.js +213 -0
- package/node_modules/@commonlyai/seats/src/wire/provider-proxy.js +529 -0
- package/package.json +56 -4
- package/skills/commonly/SKILL.md +233 -0
- package/src/commands/agent.js +6334 -0
- package/src/commands/daemon.js +933 -0
- package/src/commands/dev.js +153 -0
- package/src/commands/doctor.js +141 -0
- package/src/commands/inbox.js +330 -0
- package/src/commands/login.js +194 -0
- package/src/commands/mcp.js +56 -0
- package/src/commands/pod.js +120 -0
- package/src/commands/task.js +279 -0
- package/src/commands/verify.js +48 -0
- package/src/index.js +99 -0
- package/src/lib/adapter-login.js +76 -0
- package/src/lib/adapters/broker-bridge-entry.js +76 -0
- package/src/lib/adapters/claude.js +2207 -0
- package/src/lib/adapters/codex-broker-bridge.mjs +298 -0
- package/src/lib/adapters/codex.js +1898 -0
- package/src/lib/adapters/dsh.js +513 -0
- package/src/lib/adapters/goose.js +510 -0
- package/src/lib/adapters/hermes.js +18 -0
- package/src/lib/adapters/index.js +68 -0
- package/src/lib/adapters/opencode.js +1681 -0
- package/src/lib/adapters/pi-commonly-mcp.mjs +71 -0
- package/src/lib/adapters/pi-mcp-client.mjs +617 -0
- package/src/lib/adapters/pi-shell-env.mjs +41 -0
- package/src/lib/adapters/pi.js +1025 -0
- package/src/lib/adapters/qwen.js +523 -0
- package/src/lib/adapters/seat-model.js +8 -0
- package/src/lib/adapters/stub.js +1 -0
- package/src/lib/adapters/tested-version.js +106 -0
- package/src/lib/api.js +205 -0
- package/src/lib/claim-outcome.js +128 -0
- package/src/lib/claimed-work-gate.js +73 -0
- package/src/lib/cli-hint.js +33 -0
- package/src/lib/commit-attribution.js +391 -0
- package/src/lib/config.js +155 -0
- package/src/lib/content-refusal.js +94 -0
- package/src/lib/credential-file.js +208 -0
- package/src/lib/daemon-agent-states.js +95 -0
- package/src/lib/daemon-hint.js +18 -0
- package/src/lib/daemon-logs.js +43 -0
- package/src/lib/daemon-service-windows.js +263 -0
- package/src/lib/daemon-service.js +486 -0
- package/src/lib/daemon-state.js +84 -0
- package/src/lib/daemon-store.js +119 -0
- package/src/lib/daemon-supervisor.js +629 -0
- package/src/lib/daemon-watch.js +115 -0
- package/src/lib/declared-mcp-guard.js +284 -0
- package/src/lib/default-environment.js +207 -0
- package/src/lib/device-login.js +126 -0
- package/src/lib/digest-frame.js +151 -0
- package/src/lib/doctor.js +291 -0
- package/src/lib/egress-proxy.js +160 -0
- package/src/lib/enforcement.js +946 -0
- package/src/lib/environment.js +681 -0
- package/src/lib/first-output-watchdog.js +191 -0
- package/src/lib/git-readonly-server.js +138 -0
- package/src/lib/git-readonly.js +463 -0
- package/src/lib/git-remote-commonly.mjs +100 -0
- package/src/lib/grant-broker-guard.js +345 -0
- package/src/lib/hooks-config.js +238 -0
- package/src/lib/host-gh-broker.js +152 -0
- package/src/lib/host-gh-shim.mjs +98 -0
- package/src/lib/inbox.js +483 -0
- package/src/lib/instance-proxy.js +76 -0
- package/src/lib/machine-host.js +29 -0
- package/src/lib/mcp-credential-delivery.js +121 -0
- package/src/lib/mcp-first-run.js +50 -0
- package/src/lib/mcp-home.js +644 -0
- package/src/lib/mcp-ready.js +68 -0
- package/src/lib/mcp-server-version.js +123 -0
- package/src/lib/mcp-token.js +36 -0
- package/src/lib/mcp-warm-child.mjs +412 -0
- package/src/lib/memory-bridge.js +191 -0
- package/src/lib/memory-import.js +156 -0
- package/src/lib/missing-harness.js +31 -0
- package/src/lib/model-unavailable.js +154 -0
- package/src/lib/native-runtime-inventory.js +40 -0
- package/src/lib/outsider-grant-mcp.js +82 -0
- package/src/lib/own-key-launch.js +42 -0
- package/src/lib/pod-catch-up.js +55 -0
- package/src/lib/pod-focus.js +224 -0
- package/src/lib/pod-tail.js +68 -0
- package/src/lib/poll-liveness.js +103 -0
- package/src/lib/poll-retry.js +81 -0
- package/src/lib/poller.js +200 -0
- package/src/lib/provider-proxy.js +42 -0
- package/src/lib/public-seat-mcp-flag.js +36 -0
- package/src/lib/public-tool-error.js +60 -0
- package/src/lib/public-withheld-tools.js +48 -0
- package/src/lib/reply-receipt.js +55 -0
- package/src/lib/resolve-on-path.js +22 -0
- package/src/lib/runtime-inventory.js +56 -0
- package/src/lib/runtime-observation.js +37 -0
- package/src/lib/sandbox/bwrap.js +275 -0
- package/src/lib/sandbox/codex-perimeter.js +16 -0
- package/src/lib/sandbox/host-check.js +268 -0
- package/src/lib/sandbox/mode.js +49 -0
- package/src/lib/sandbox/opencode-loopback.mjs +68 -0
- package/src/lib/sandbox/seatbelt.js +684 -0
- package/src/lib/sandbox/turn-dir.js +56 -0
- package/src/lib/sandbox/windows-unsandboxed.js +183 -0
- package/src/lib/sandbox/workspace-git-config.js +243 -0
- package/src/lib/sandbox/workspace-pin.js +158 -0
- package/src/lib/seat-runtime-controls.js +61 -0
- package/src/lib/seat-stop.js +97 -0
- package/src/lib/server-skills.js +32 -0
- package/src/lib/session-recycle.js +219 -0
- package/src/lib/session-store.js +215 -0
- package/src/lib/skill-prompt.js +74 -0
- package/src/lib/skills-import.js +113 -0
- package/src/lib/spawn-credential.js +402 -0
- package/src/lib/spawn-retry.js +311 -0
- package/src/lib/stable-install.js +176 -0
- package/src/lib/supervised-turn.js +352 -0
- package/src/lib/task.js +235 -0
- package/src/lib/turn-failure-notice.js +177 -0
- package/src/lib/unconfined-seat-guard.js +94 -0
- package/src/lib/update-check.js +192 -0
- package/src/lib/upstream-refusal.js +1 -0
- package/src/lib/webhook-server.js +109 -0
- package/src/lib/windows-command.js +393 -0
- package/src/lib/workspace-git.js +87 -0
- package/src/lib/workspace-trust.js +162 -0
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One model turn of `commonly agent run`, on one of two paths. The supervised
|
|
3
|
+
* path also writes one metadata line per turn (the turn log), once the
|
|
4
|
+
* turn's outcome has reached the pod or failed to.
|
|
5
|
+
*
|
|
6
|
+
* codex by default, or COMMONLY_SUPERVISE_RUNS=1 (superviseRunsByDefault in
|
|
7
|
+
* commands/agent.js; COMMONLY_SUPERVISE_RUNS=0 opts codex out)
|
|
8
|
+
* the turn runs under `superviseRun` from @commonlyai/seats: a stall
|
|
9
|
+
* window, a wall-clock backstop, and a guarantee the turn settles with a
|
|
10
|
+
* named failure. No provider relay: the CLI talks to its provider directly,
|
|
11
|
+
* exactly as on the plain path.
|
|
12
|
+
* anything else
|
|
13
|
+
* the plain path: `adapter.spawn(prompt, ctx)` with the caller's ctx object
|
|
14
|
+
* untouched, and nothing written: the flag-off run loop is main's.
|
|
15
|
+
*
|
|
16
|
+
* A turn is ok only when its outcome reached the pod: the reply was delivered
|
|
17
|
+
* or the agent chose silence. The run loop names the outcome after delivery
|
|
18
|
+
* settles (turn.finish); a spawn that threw is `failed` at once.
|
|
19
|
+
*
|
|
20
|
+
* NO RECORD WRITER. The supervisor needs a trace store. The one here
|
|
21
|
+
* (createMetadataStore) is in memory, lives for one turn, and keeps event type
|
|
22
|
+
* names, times and the recovery action word. It drops every event's data, so
|
|
23
|
+
* no agent text, prompt, reply, failure detail or credential is kept, and
|
|
24
|
+
* nothing is written to disk (a redaction review on #2701).
|
|
25
|
+
*
|
|
26
|
+
* Metrics: ~/.commonly/state/turns/<seat>.jsonl (COMMONLY_TURN_LOG_DIR
|
|
27
|
+
* overrides the directory). Whitelisted fields only; see metricLine.
|
|
28
|
+
*/
|
|
29
|
+
import { appendFileSync, mkdirSync } from 'fs';
|
|
30
|
+
import { homedir } from 'os';
|
|
31
|
+
import { dirname, join } from 'path';
|
|
32
|
+
import { superviseRun as defaultSuperviseRun } from '@commonlyai/seats/supervisor';
|
|
33
|
+
import { isEventType } from '@commonlyai/seats/trace/events';
|
|
34
|
+
import { failureKindOf } from '@commonlyai/seats/failures';
|
|
35
|
+
import { classifySpawnFailure } from './spawn-retry.js';
|
|
36
|
+
|
|
37
|
+
const MAX_TIMER_MS = 2_147_483_647;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Stall windows per adapter, in ms; 0 is off. Unlisted adapters: off.
|
|
41
|
+
*
|
|
42
|
+
* codex: `codex exec --json` writes a JSONL event per item, so its stdout is a
|
|
43
|
+
* real progress signal, and the quiet stretch is the longest single item (a
|
|
44
|
+
* reasoning step or one shell command). Measured 2026-10-25 from per-turn
|
|
45
|
+
* timestamp gaps in October codex rollouts on one host (~/.codex/sessions):
|
|
46
|
+
* 9,703 turns, max quiet gap 162 s, p99 53 s; 9 turns would have tripped the
|
|
47
|
+
* package default of 120 s. 15 min is 5.5x the worst gap seen and still
|
|
48
|
+
* 30 min inside the seats' 45 min turn budget.
|
|
49
|
+
* claude: the adapter runs `claude -p --output-format text`, which writes
|
|
50
|
+
* nothing to stdout until the reply is done. A healthy 14-minute claude turn
|
|
51
|
+
* is 14 minutes of silence to the supervisor, so any window
|
|
52
|
+
* under the turn budget would be a turn-length cap, not a liveness check.
|
|
53
|
+
* Off; claude is guarded by the wall-clock backstop and the cancel ladder.
|
|
54
|
+
*/
|
|
55
|
+
export const STALL_DEFAULTS_MS = Object.freeze({ codex: 15 * 60 * 1000, claude: 0 });
|
|
56
|
+
// How long after the supervisor's abort before it SIGKILLs the child's tree
|
|
57
|
+
// (process-group.js). The claude and codex adapters do not act on the abort,
|
|
58
|
+
// so on a stall the child is alive at the kill and its whole tree goes with it:
|
|
59
|
+
// a SIGTERM to the CLI alone would let it exit first and orphan the MCP
|
|
60
|
+
// servers it started in groups of their own, which the tree kill then skips.
|
|
61
|
+
// An adapter that honours ctx.signal gets this long to settle.
|
|
62
|
+
export const CANCEL_GRACE_MS = 15_000;
|
|
63
|
+
// The wall clock sits past the adapter's own timeout, so the adapter's timeout
|
|
64
|
+
// (the same SIGTERM and error as on the plain path) fires first; the
|
|
65
|
+
// supervisor's only settles a child that outlived it.
|
|
66
|
+
export const WALL_BACKSTOP_MS = 60_000;
|
|
67
|
+
// After a failed turn, how long to wait for the child's exit status before
|
|
68
|
+
// writing the metric line anyway (the kill lands just before settle).
|
|
69
|
+
const EXIT_WAIT_MS = 3_000;
|
|
70
|
+
|
|
71
|
+
const envName = (prefix, adapterName) => `${prefix}_${String(adapterName || '').toUpperCase().replace(/[^A-Z0-9]/g, '_')}`;
|
|
72
|
+
|
|
73
|
+
const envMs = (env, name, warnings) => {
|
|
74
|
+
const raw = env?.[name];
|
|
75
|
+
if (raw === undefined || raw === '') return undefined;
|
|
76
|
+
const value = Number(raw);
|
|
77
|
+
if (!Number.isInteger(value) || value < 0) {
|
|
78
|
+
warnings.push(`${name} ignored (want a non-negative integer of ms)`);
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
return Math.min(value, MAX_TIMER_MS);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The supervisor policy for one adapter's turn. Env overrides, most specific
|
|
86
|
+
* first: COMMONLY_SUPERVISE_STALL_MS_<ADAPTER>, COMMONLY_SUPERVISE_STALL_MS,
|
|
87
|
+
* COMMONLY_SUPERVISE_CANCEL_GRACE_MS, COMMONLY_SUPERVISE_WALL_BACKSTOP_MS.
|
|
88
|
+
* 0 turns a window off. No retries: the run loop keeps those.
|
|
89
|
+
*/
|
|
90
|
+
export const supervisedRunPolicy = (turnTimeoutMs, { adapterName = null, env = process.env, warnings = [] } = {}) => {
|
|
91
|
+
const stallMs = envMs(env, envName('COMMONLY_SUPERVISE_STALL_MS', adapterName), warnings)
|
|
92
|
+
?? envMs(env, 'COMMONLY_SUPERVISE_STALL_MS', warnings)
|
|
93
|
+
?? STALL_DEFAULTS_MS[adapterName] ?? 0;
|
|
94
|
+
const cancelGraceMs = envMs(env, 'COMMONLY_SUPERVISE_CANCEL_GRACE_MS', warnings) ?? CANCEL_GRACE_MS;
|
|
95
|
+
const backstopMs = envMs(env, 'COMMONLY_SUPERVISE_WALL_BACKSTOP_MS', warnings) ?? WALL_BACKSTOP_MS;
|
|
96
|
+
return {
|
|
97
|
+
stallMs,
|
|
98
|
+
cancelGraceMs,
|
|
99
|
+
timeoutMs: Math.min(Number(turnTimeoutMs) + backstopMs, MAX_TIMER_MS),
|
|
100
|
+
};
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* A trace store that keeps no agent text and writes nothing. superviseRun
|
|
105
|
+
* calls startRun once per turn; each event keeps { type, at, action? }.
|
|
106
|
+
*/
|
|
107
|
+
export const createMetadataStore = ({ now = Date.now } = {}) => {
|
|
108
|
+
let current = null;
|
|
109
|
+
let seq = 0;
|
|
110
|
+
return {
|
|
111
|
+
startRun({ runId } = {}) {
|
|
112
|
+
seq += 1;
|
|
113
|
+
const events = [];
|
|
114
|
+
let settled = false;
|
|
115
|
+
current = {
|
|
116
|
+
runId: runId || `turn-${now().toString(36)}-${seq}`,
|
|
117
|
+
emit(type, data) {
|
|
118
|
+
if (!isEventType(type)) throw new Error(`unknown trace event type: ${type}`);
|
|
119
|
+
if (settled) return null;
|
|
120
|
+
const entry = { type, at: now() };
|
|
121
|
+
if (type === 'liveness.recovery' && typeof data?.action === 'string') entry.action = data.action;
|
|
122
|
+
events.push(entry);
|
|
123
|
+
if (type === 'run.settled') settled = true;
|
|
124
|
+
return entry;
|
|
125
|
+
},
|
|
126
|
+
get settled() { return settled; },
|
|
127
|
+
events: () => events.map((entry) => ({ ...entry })),
|
|
128
|
+
};
|
|
129
|
+
return current;
|
|
130
|
+
},
|
|
131
|
+
lastRun: () => current,
|
|
132
|
+
};
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
export const metricsPathFor = (seat, env = process.env) => {
|
|
136
|
+
const dir = env?.COMMONLY_TURN_LOG_DIR || join(homedir(), '.commonly', 'state', 'turns');
|
|
137
|
+
const safe = String(seat || 'unknown').replace(/[^A-Za-z0-9_.-]/g, '_').slice(0, 64) || 'unknown';
|
|
138
|
+
return join(dir, `${safe}.jsonl`);
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/** A failure's name, never its text. RunFailure kinds are the supervisor's words. */
|
|
142
|
+
export const failureNameOf = (error) => {
|
|
143
|
+
const kind = failureKindOf(error) || (typeof error?.kind === 'string' && /^[a-z_]{1,40}$/.test(error.kind) ? error.kind : null);
|
|
144
|
+
if (kind) return { name: kind, source: 'supervisor' };
|
|
145
|
+
const message = String(error?.message ?? error ?? '');
|
|
146
|
+
if (/\btimed out after \d+ms\b/.test(message)) return { name: 'adapter_timeout', source: 'adapter' };
|
|
147
|
+
if (/\bexited with code\b/.test(message)) return { name: 'adapter_exit', source: 'adapter' };
|
|
148
|
+
return { name: 'adapter_error', source: 'adapter' };
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const failureClassOf = (error) => {
|
|
152
|
+
try { return classifySpawnFailure(error); } catch { return null; }
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
// What happened to a turn, named by the run loop once delivery settles.
|
|
156
|
+
export const TURN_OUTCOMES = Object.freeze([
|
|
157
|
+
'delivered', // the reply reached the pod (wrapper post, the agent's own post, or an ask response)
|
|
158
|
+
'silent', // NO_REPLY, a heartbeat control word, or empty output: nothing to post
|
|
159
|
+
'reply_lost', // the model answered and every delivery rung threw
|
|
160
|
+
'claim_lost', // the lease lapsed mid-turn and a peer owns the message; reply suppressed
|
|
161
|
+
'server_skipped', // the server answered and created nothing (a duplicate, a kept heartbeat)
|
|
162
|
+
'server_refused', // the server refused the post (a run cap)
|
|
163
|
+
'upstream_refused', // the model route refused; the CLI exited with nothing to say
|
|
164
|
+
]);
|
|
165
|
+
export const OK_OUTCOMES = Object.freeze(['delivered', 'silent']);
|
|
166
|
+
|
|
167
|
+
const EVENT_TYPE_RE = /^[A-Za-z0-9._-]{1,40}$/;
|
|
168
|
+
const iso = (ms) => new Date(ms).toISOString();
|
|
169
|
+
const int = (value) => (Number.isInteger(value) ? value : null);
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The metric line. Every field is listed here, each a number, boolean, null or
|
|
173
|
+
* a short fixed word; nothing from the prompt, the reply, a failure's detail or
|
|
174
|
+
* the environment can reach it.
|
|
175
|
+
*/
|
|
176
|
+
export const metricLine = (f) => {
|
|
177
|
+
const failed = f.outcome === 'failed';
|
|
178
|
+
const outcome = failed || TURN_OUTCOMES.includes(f.outcome) ? f.outcome : 'unknown';
|
|
179
|
+
return {
|
|
180
|
+
v: 1,
|
|
181
|
+
ts: iso(f.settledMs ?? f.endMs),
|
|
182
|
+
seat: String(f.seat || 'unknown').replace(/[^A-Za-z0-9_.-]/g, '_').slice(0, 64),
|
|
183
|
+
adapter: String(f.adapter || 'unknown').replace(/[^A-Za-z0-9_.-]/g, '_').slice(0, 32),
|
|
184
|
+
path: f.path === 'supervised' ? 'supervised' : 'legacy',
|
|
185
|
+
eventType: EVENT_TYPE_RE.test(String(f.eventType || '')) ? f.eventType : null,
|
|
186
|
+
turnStart: iso(f.startMs),
|
|
187
|
+
turnEnd: iso(f.endMs),
|
|
188
|
+
durationMs: Math.max(0, f.endMs - f.startMs),
|
|
189
|
+
outcome,
|
|
190
|
+
ok: OK_OUTCOMES.includes(outcome),
|
|
191
|
+
failure: failed ? (f.failureName || 'adapter_error') : null,
|
|
192
|
+
failureSource: failed ? (f.failureSource || null) : null,
|
|
193
|
+
failureClass: failed && typeof f.failureClass === 'string' ? f.failureClass : null,
|
|
194
|
+
stalled: !!f.stalled,
|
|
195
|
+
cancelled: !!f.cancelled,
|
|
196
|
+
killed: !!f.killed,
|
|
197
|
+
exitCode: int(f.exitCode),
|
|
198
|
+
signal: typeof f.signal === 'string' && /^SIG[A-Z0-9]+$/.test(f.signal) ? f.signal : null,
|
|
199
|
+
exitObserved: !!f.exitObserved,
|
|
200
|
+
childLeftRunning: typeof f.childLeftRunning === 'boolean' ? f.childLeftRunning : null,
|
|
201
|
+
attempts: int(f.attempts),
|
|
202
|
+
stallMs: int(f.policy?.stallMs),
|
|
203
|
+
cancelGraceMs: int(f.policy?.cancelGraceMs),
|
|
204
|
+
timeoutMs: int(f.policy?.timeoutMs),
|
|
205
|
+
};
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const isChild = (value) => !!value && typeof value.once === 'function' && typeof value.kill === 'function';
|
|
209
|
+
const isAlive = (child) => !!child && child.exitCode === null && child.signalCode === null;
|
|
210
|
+
|
|
211
|
+
const waitForExit = (child, ms) => new Promise((resolve) => {
|
|
212
|
+
if (!isAlive(child)) { resolve(); return; }
|
|
213
|
+
const timer = setTimeout(resolve, ms);
|
|
214
|
+
timer.unref?.();
|
|
215
|
+
child.once('exit', () => { clearTimeout(timer); resolve(); });
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* @param {object} args
|
|
220
|
+
* @param {string} args.seat agent name; names the metrics file
|
|
221
|
+
* @param {number} args.turnTimeoutMs the run loop's effective turn budget
|
|
222
|
+
* @param {boolean} args.supervise performRun's superviseRuns
|
|
223
|
+
* @param {(line: string) => void} [args.log]
|
|
224
|
+
* @param {object} [args.env]
|
|
225
|
+
* @param {Function} [args.superviseRun] injectable for tests
|
|
226
|
+
*/
|
|
227
|
+
export const createTurnRunner = ({
|
|
228
|
+
seat,
|
|
229
|
+
turnTimeoutMs,
|
|
230
|
+
supervise = false,
|
|
231
|
+
log = () => {},
|
|
232
|
+
env = process.env,
|
|
233
|
+
now = Date.now,
|
|
234
|
+
superviseRun = defaultSuperviseRun,
|
|
235
|
+
} = {}) => {
|
|
236
|
+
const metricsPath = metricsPathFor(seat, env);
|
|
237
|
+
let metricsWarned = false;
|
|
238
|
+
|
|
239
|
+
const record = (fields) => {
|
|
240
|
+
try {
|
|
241
|
+
mkdirSync(dirname(metricsPath), { recursive: true, mode: 0o700 });
|
|
242
|
+
appendFileSync(metricsPath, `${JSON.stringify(metricLine({ seat, ...fields }))}\n`, { mode: 0o600 });
|
|
243
|
+
} catch (error) {
|
|
244
|
+
// Metrics never fail a turn. Said once per process, not per turn.
|
|
245
|
+
if (!metricsWarned) {
|
|
246
|
+
metricsWarned = true;
|
|
247
|
+
log(`turn metrics not written (${error?.code || 'error'}); turns are unaffected`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
/** The boot line for the supervised path; the plain path logs nothing new. */
|
|
253
|
+
const describe = (adapterName) => {
|
|
254
|
+
const warnings = [];
|
|
255
|
+
const policy = supervisedRunPolicy(turnTimeoutMs, { adapterName, env, warnings });
|
|
256
|
+
return `supervised runs ON for ${adapterName}: stall=${policy.stallMs ? `${policy.stallMs}ms` : 'off'}, `
|
|
257
|
+
+ `cancel grace=${policy.cancelGraceMs}ms, wall=${policy.timeoutMs}ms, `
|
|
258
|
+
+ 'trace=memory (no record written); '
|
|
259
|
+
+ `metrics → ${metricsPath}${warnings.length ? `; ${warnings.join('; ')}` : ''}`;
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
const runSupervised = async (adapter, prompt, ctx, eventType, hold) => {
|
|
263
|
+
const startMs = now();
|
|
264
|
+
const policy = supervisedRunPolicy(turnTimeoutMs, { adapterName: adapter?.name, env });
|
|
265
|
+
const store = createMetadataStore({ now });
|
|
266
|
+
let child = null;
|
|
267
|
+
let exit = null;
|
|
268
|
+
const callerOnSpawn = ctx?.onSpawn;
|
|
269
|
+
const onSpawn = (...args) => {
|
|
270
|
+
const proc = args.find(isChild);
|
|
271
|
+
if (proc) {
|
|
272
|
+
child = proc;
|
|
273
|
+
exit = null;
|
|
274
|
+
proc.once('exit', (code, signal) => { if (child === proc) exit = { code, signal }; });
|
|
275
|
+
}
|
|
276
|
+
return typeof callerOnSpawn === 'function' ? callerOnSpawn(...args) : undefined;
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
const supervised = await superviseRun({
|
|
280
|
+
adapter,
|
|
281
|
+
prompt,
|
|
282
|
+
ctx: { ...ctx, onSpawn },
|
|
283
|
+
store,
|
|
284
|
+
policy,
|
|
285
|
+
label: EVENT_TYPE_RE.test(String(eventType || '')) ? eventType : undefined,
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
const ok = !supervised.failure;
|
|
289
|
+
if (!ok) await waitForExit(child, EXIT_WAIT_MS);
|
|
290
|
+
const events = store.lastRun()?.events() || [];
|
|
291
|
+
const recovered = (action) => events.some((e) => e.type === 'liveness.recovery' && e.action === action);
|
|
292
|
+
const killed = recovered('kill');
|
|
293
|
+
const named = ok ? null : failureNameOf(supervised.failure);
|
|
294
|
+
const fields = {
|
|
295
|
+
adapter: adapter?.name,
|
|
296
|
+
path: 'supervised',
|
|
297
|
+
eventType,
|
|
298
|
+
startMs,
|
|
299
|
+
endMs: now(),
|
|
300
|
+
outcome: ok ? undefined : 'failed',
|
|
301
|
+
failureName: named?.name,
|
|
302
|
+
failureSource: named?.source,
|
|
303
|
+
failureClass: ok ? null : failureClassOf(supervised.failure),
|
|
304
|
+
stalled: events.some((e) => e.type === 'liveness.stalled'),
|
|
305
|
+
cancelled: recovered('cancel'),
|
|
306
|
+
killed,
|
|
307
|
+
exitCode: exit ? exit.code : null,
|
|
308
|
+
signal: exit ? exit.signal : null,
|
|
309
|
+
exitObserved: !!exit,
|
|
310
|
+
childLeftRunning: child ? isAlive(child) : null,
|
|
311
|
+
attempts: supervised.attempts,
|
|
312
|
+
policy,
|
|
313
|
+
};
|
|
314
|
+
// A spawn that resolved is not yet an ok turn: the line waits for the run
|
|
315
|
+
// loop to say what became of the reply.
|
|
316
|
+
if (ok) { hold(fields); return supervised.result; }
|
|
317
|
+
record(fields);
|
|
318
|
+
if (named.source === 'supervisor') {
|
|
319
|
+
// The name goes in the seat log. The throw takes the road a plain-path
|
|
320
|
+
// failure takes: unacked, the run loop's retry policy, onError.
|
|
321
|
+
log(`[${eventType}] supervised turn failed: ${named.name}${killed ? ' (child killed)' : ''}`
|
|
322
|
+
+ `${isAlive(child) ? ' (child still running)' : ''}`);
|
|
323
|
+
}
|
|
324
|
+
throw supervised.failure;
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
return {
|
|
328
|
+
supervise,
|
|
329
|
+
metricsPath,
|
|
330
|
+
describe,
|
|
331
|
+
/**
|
|
332
|
+
* One model turn. `run` has adapter.spawn's contract; `finish(outcome)`
|
|
333
|
+
* writes the line once delivery has settled. Only the first finish counts,
|
|
334
|
+
* and a finish with no spawned turn behind it writes nothing.
|
|
335
|
+
*/
|
|
336
|
+
begin({ eventType } = {}) {
|
|
337
|
+
let held = null;
|
|
338
|
+
let done = false;
|
|
339
|
+
return {
|
|
340
|
+
run(adapter, prompt, ctx) {
|
|
341
|
+
if (!supervise) return adapter.spawn(prompt, ctx);
|
|
342
|
+
return runSupervised(adapter, prompt, ctx, eventType, (fields) => { held = fields; });
|
|
343
|
+
},
|
|
344
|
+
finish(outcome) {
|
|
345
|
+
if (done || !held) return;
|
|
346
|
+
done = true;
|
|
347
|
+
record({ ...held, outcome, settledMs: now() });
|
|
348
|
+
},
|
|
349
|
+
};
|
|
350
|
+
},
|
|
351
|
+
};
|
|
352
|
+
};
|
package/src/lib/task.js
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The task board from the CLI: identity, the request layer, and one-line
|
|
3
|
+
* rendering. The routes are the ones the commonly_* task tools call
|
|
4
|
+
* (backend/routes/tasksApi.ts), so the CLI and the MCP cannot disagree about
|
|
5
|
+
* what a write means.
|
|
6
|
+
*
|
|
7
|
+
* Identity is an explicit token and nothing else: --token-file, then
|
|
8
|
+
* COMMONLY_TOKEN_FILE, then COMMONLY_AGENT_TOKEN. The saved login and
|
|
9
|
+
* COMMONLY_TOKEN are never read. A write made under an operator's saved login
|
|
10
|
+
* lands on the row under that operator's name.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { readFileSync } from 'fs';
|
|
14
|
+
import { DEFAULT_URL, resolveInstance } from './config.js';
|
|
15
|
+
import { extractToken, resolveTokenFilePath, TOKEN_FILE_ENV } from './inbox.js';
|
|
16
|
+
import { CLI } from './cli-hint.js';
|
|
17
|
+
|
|
18
|
+
export const API_URL_ENV = 'COMMONLY_API_URL';
|
|
19
|
+
export const TASK_TOKEN_ENV = 'COMMONLY_AGENT_TOKEN';
|
|
20
|
+
export const TASK_STATUSES = ['pending', 'claimed', 'done', 'blocked'];
|
|
21
|
+
// `update` writes only these. A claim writes the lease, which /claim owns, and a
|
|
22
|
+
// close stamps completedAt and the PR, which PATCH does not.
|
|
23
|
+
export const UPDATE_STATUSES = ['pending', 'blocked'];
|
|
24
|
+
export const TASK_PRIORITIES = ['P0', 'P1', 'P2', 'P3'];
|
|
25
|
+
const REQUEST_TIMEOUT_MS = 30_000;
|
|
26
|
+
|
|
27
|
+
/** A refusal made before any request: a bad flag, or no identity to send. */
|
|
28
|
+
export class TaskRefusal extends Error {}
|
|
29
|
+
|
|
30
|
+
/** A failed call. `status` is 0 when the server was never reached. */
|
|
31
|
+
export class TaskRequestError extends Error {
|
|
32
|
+
constructor(message, { status = 0, code = null, body = null } = {}) {
|
|
33
|
+
super(message);
|
|
34
|
+
this.name = 'TaskRequestError';
|
|
35
|
+
this.status = status;
|
|
36
|
+
this.code = code;
|
|
37
|
+
this.body = body;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const requireText = (value, flag) => {
|
|
42
|
+
const text = typeof value === 'string' ? value.trim() : '';
|
|
43
|
+
if (!text) throw new TaskRefusal(`${flag} needs a value.`);
|
|
44
|
+
return text;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export const checkPriority = (value) => {
|
|
48
|
+
const priority = String(value).trim().toUpperCase();
|
|
49
|
+
if (!TASK_PRIORITIES.includes(priority)) {
|
|
50
|
+
throw new TaskRefusal(`--priority must be ${TASK_PRIORITIES.join(', ')} (got '${value}').`);
|
|
51
|
+
}
|
|
52
|
+
return priority;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export const checkStatusFilter = (value) => {
|
|
56
|
+
const statuses = String(value).split(',').map((s) => s.trim()).filter(Boolean);
|
|
57
|
+
if (statuses.length === 0 || statuses.some((s) => !TASK_STATUSES.includes(s))) {
|
|
58
|
+
throw new TaskRefusal(`--status takes ${TASK_STATUSES.join(', ')}, comma-separated (got '${value}').`);
|
|
59
|
+
}
|
|
60
|
+
return statuses.join(',');
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export const checkUpdateStatus = (value) => {
|
|
64
|
+
const status = String(value).trim().toLowerCase();
|
|
65
|
+
if (status === 'claimed') {
|
|
66
|
+
throw new TaskRefusal(`--status claimed is refused: \`${CLI} task claim\` writes the lease, and a bare status does not.`);
|
|
67
|
+
}
|
|
68
|
+
if (status === 'done') {
|
|
69
|
+
throw new TaskRefusal(`--status done is refused: \`${CLI} task close\` stamps completedAt and the PR, and PATCH does not.`);
|
|
70
|
+
}
|
|
71
|
+
if (!UPDATE_STATUSES.includes(status)) {
|
|
72
|
+
throw new TaskRefusal(`--status must be ${UPDATE_STATUSES.join(' or ')} (got '${value}').`);
|
|
73
|
+
}
|
|
74
|
+
return status;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** TASK-669, task-669 and 669 name one row. Anything else is refused before a request. */
|
|
78
|
+
export const checkTaskId = (value) => {
|
|
79
|
+
const match = String(value ?? '').trim().match(/^(?:task-)?(\d+)$/i);
|
|
80
|
+
if (!match) throw new TaskRefusal(`task id must look like TASK-123 (got '${value ?? ''}').`);
|
|
81
|
+
return `TASK-${String(Number(match[1])).padStart(3, '0')}`;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Resolve the token. A token file that exists but holds nothing is refused by
|
|
86
|
+
* its path. The message never suggests `commonly login`, because pointing back
|
|
87
|
+
* at the saved login is the thing this rule exists to prevent.
|
|
88
|
+
*/
|
|
89
|
+
export const resolveTaskToken = (opts = {}, env = process.env) => {
|
|
90
|
+
const tokenFile = resolveTokenFilePath(opts, env);
|
|
91
|
+
if (tokenFile) {
|
|
92
|
+
let raw;
|
|
93
|
+
try {
|
|
94
|
+
raw = readFileSync(tokenFile, 'utf8');
|
|
95
|
+
} catch (err) {
|
|
96
|
+
throw new TaskRefusal(`cannot read the token file ${tokenFile} (${err?.code || err?.message}).`);
|
|
97
|
+
}
|
|
98
|
+
const token = extractToken(raw);
|
|
99
|
+
if (!token) throw new TaskRefusal(`no token in ${tokenFile}. Expected a raw token, or JSON with a token field.`);
|
|
100
|
+
return token;
|
|
101
|
+
}
|
|
102
|
+
const fromEnv = extractToken(env[TASK_TOKEN_ENV]);
|
|
103
|
+
if (fromEnv) return fromEnv;
|
|
104
|
+
throw new TaskRefusal(`no token. Pass --token-file <path>, or set ${TOKEN_FILE_ENV} or ${TASK_TOKEN_ENV}.`);
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* --instance takes a URL or a saved key, and an unknown key is refused. Falling
|
|
109
|
+
* back to the default host would send this token to an instance it was not
|
|
110
|
+
* minted on.
|
|
111
|
+
*/
|
|
112
|
+
export const resolveTaskBaseUrl = (opts = {}, env = process.env) => {
|
|
113
|
+
if (opts.instance) {
|
|
114
|
+
const inst = resolveInstance(opts.instance);
|
|
115
|
+
if (!inst?.url) {
|
|
116
|
+
throw new TaskRefusal(`unknown instance '${opts.instance}'. Pass its URL, or a saved instance key.`);
|
|
117
|
+
}
|
|
118
|
+
return inst.url.replace(/\/$/, '');
|
|
119
|
+
}
|
|
120
|
+
const fromEnv = String(env[API_URL_ENV] || '').trim().replace(/\/$/, '');
|
|
121
|
+
return fromEnv || DEFAULT_URL;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/** `/api/v1/tasks/<pod>` for the board, and `…/<taskId>` for one row. */
|
|
125
|
+
export const taskPath = (podId, taskId = null) => [
|
|
126
|
+
'/api/v1/tasks',
|
|
127
|
+
encodeURIComponent(podId),
|
|
128
|
+
...(taskId === null ? [] : [encodeURIComponent(taskId)]),
|
|
129
|
+
].join('/');
|
|
130
|
+
|
|
131
|
+
const messageFor = (status, body, text) => {
|
|
132
|
+
if (body && typeof body.error === 'string' && body.error) return body.error;
|
|
133
|
+
if (body && typeof body.message === 'string' && body.message) return body.message;
|
|
134
|
+
const trimmed = String(text || '').trim();
|
|
135
|
+
if (!trimmed) return `HTTP ${status}`;
|
|
136
|
+
if (/^<(?:!doctype|html)/i.test(trimmed)) {
|
|
137
|
+
const title = trimmed.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1];
|
|
138
|
+
return title ? title.replace(/\s+/g, ' ').trim() : 'HTML error page';
|
|
139
|
+
}
|
|
140
|
+
return trimmed.replace(/\s+/g, ' ').slice(0, 200);
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* One attempt per call. The writes here are POSTs, and a retried create or note
|
|
145
|
+
* lands twice, so a failure is reported and the caller decides what to do next.
|
|
146
|
+
*/
|
|
147
|
+
export const createTaskClient = ({ baseUrl, token, fetchImpl = fetch }) => {
|
|
148
|
+
const send = async (method, path, body) => {
|
|
149
|
+
let res;
|
|
150
|
+
try {
|
|
151
|
+
res = await fetchImpl(`${baseUrl}${path}`, {
|
|
152
|
+
method,
|
|
153
|
+
headers: {
|
|
154
|
+
Authorization: `Bearer ${token}`,
|
|
155
|
+
'Content-Type': 'application/json',
|
|
156
|
+
},
|
|
157
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
158
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
159
|
+
});
|
|
160
|
+
} catch (err) {
|
|
161
|
+
throw new TaskRequestError(`could not reach ${baseUrl} (${err?.message || err}).`);
|
|
162
|
+
}
|
|
163
|
+
const text = await res.text();
|
|
164
|
+
let parsed = null;
|
|
165
|
+
try {
|
|
166
|
+
parsed = text ? JSON.parse(text) : null;
|
|
167
|
+
} catch {
|
|
168
|
+
parsed = null;
|
|
169
|
+
}
|
|
170
|
+
if (!res.ok) {
|
|
171
|
+
throw new TaskRequestError(messageFor(res.status, parsed, text), {
|
|
172
|
+
status: res.status,
|
|
173
|
+
code: parsed?.code || null,
|
|
174
|
+
body: parsed,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
return parsed;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const get = (path, params = {}) => {
|
|
181
|
+
const query = new URLSearchParams(
|
|
182
|
+
Object.entries(params).filter(([, value]) => value !== undefined && value !== ''),
|
|
183
|
+
).toString();
|
|
184
|
+
return send('GET', query ? `${path}?${query}` : path);
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
get,
|
|
189
|
+
post: (path, body = {}) => send('POST', path, body),
|
|
190
|
+
patch: (path, body = {}) => send('PATCH', path, body),
|
|
191
|
+
};
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const clockOf = (iso) => {
|
|
195
|
+
if (!iso) return null;
|
|
196
|
+
const at = new Date(iso);
|
|
197
|
+
return Number.isNaN(at.getTime()) ? null : `${at.toISOString().slice(11, 16)}Z`;
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
/** One row, as a human reads it. A claimed row also says whether its lease holds. */
|
|
201
|
+
export const formatTaskLine = (task) => {
|
|
202
|
+
const line = [task.taskId, task.status, task.priority || '-', task.assignee || '-', task.title].join(' ');
|
|
203
|
+
if (task.status !== 'claimed') return line;
|
|
204
|
+
const at = clockOf(task.claimExpiresAt);
|
|
205
|
+
if (!at) return line;
|
|
206
|
+
const lapsed = new Date(task.claimExpiresAt).getTime() < Date.now();
|
|
207
|
+
return `${line} (${lapsed ? 'lease lapsed' : 'held until'} ${at})`;
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* One line for a failed call. The unseen-messages hold and a lease conflict get
|
|
212
|
+
* their own wording, because a seat acts on each differently. Everything else is
|
|
213
|
+
* the server's own text.
|
|
214
|
+
*/
|
|
215
|
+
export const describeFailure = (err) => {
|
|
216
|
+
if (!(err instanceof TaskRequestError)) return `Failed: ${err?.message || err}`;
|
|
217
|
+
if (err.status === 0) return `Failed: ${err.message}`;
|
|
218
|
+
const body = err.body || {};
|
|
219
|
+
let line;
|
|
220
|
+
if (err.code === 'unseen_messages') {
|
|
221
|
+
const count = Number(body.unseenCount) || 0;
|
|
222
|
+
line = `Failed (HTTP ${err.status}): ${count} unseen message${count === 1 ? '' : 's'} in this pod. `
|
|
223
|
+
+ 'Read them and retry, or pass --continue-anyway to go ahead.';
|
|
224
|
+
} else {
|
|
225
|
+
const detail = [
|
|
226
|
+
body.claimedBy ? `held by ${body.claimedBy}` : null,
|
|
227
|
+
clockOf(body.claimExpiresAt) ? `lease until ${clockOf(body.claimExpiresAt)}` : null,
|
|
228
|
+
].filter(Boolean).join(', ');
|
|
229
|
+
line = `Failed (HTTP ${err.status}): ${err.message}${detail ? ` (${detail})` : ''}`;
|
|
230
|
+
if (err.status === 401) {
|
|
231
|
+
line += '. Check the agent token: --token-file, COMMONLY_TOKEN_FILE or COMMONLY_AGENT_TOKEN.';
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return line.replace(/\s+/g, ' ');
|
|
235
|
+
};
|