@volter/supercode-orchestrator 0.5.205 → 0.5.206
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/README.md +138 -4
- package/activation.mjs +93 -2
- package/adapters/rh2.mjs +7 -2
- package/agents.mjs +24 -2
- package/bin/agent-channel.mjs +41 -22
- package/bin/connection-runner.mjs +141 -0
- package/bin/orchestrator.mjs +11 -2
- package/bin/runtime.mjs +16 -9
- package/board/args.mjs +3 -2
- package/board/attachment-read.mjs +77 -0
- package/board/call.mjs +4 -2
- package/board/capacity.mjs +46 -9
- package/board/ci.mjs +157 -53
- package/board/cli.mjs +225 -87
- package/board/client.mjs +18 -2
- package/board/custody.mjs +109 -0
- package/board/dispatch.mjs +297 -75
- package/board/doors.mjs +47 -17
- package/board/engine.mjs +78 -24
- package/board/fleet-listeners.mjs +268 -0
- package/board/fleet-observer.mjs +79 -0
- package/board/fleet-targets.mjs +82 -0
- package/board/incidents.mjs +7 -0
- package/board/listing.mjs +5 -0
- package/board/machine-fleet.mjs +7 -0
- package/board/notice-receipts.mjs +67 -0
- package/board/owner-door-worker.mjs +61 -0
- package/board/owner-door.mjs +46 -0
- package/board/owner-health.mjs +8 -2
- package/board/published.mjs +2 -2
- package/board/record-view.mjs +2 -0
- package/board/store.mjs +55 -3
- package/board/tools.mjs +11 -4
- package/board/workflow.mjs +91 -28
- package/board/ztrack.mjs +87 -21
- package/board-input.d.ts +2 -0
- package/board-input.mjs +16 -0
- package/connection.mjs +153 -0
- package/index.d.ts +7 -5
- package/index.mjs +1 -1
- package/installed-apps.mjs +31 -20
- package/loop.mjs +91 -36
- package/mcp/bridge.mjs +12 -0
- package/mcp/hermes-kanban-tools.json +22 -14
- package/mcp/mount.mjs +10 -9
- package/ontology.d.ts +37 -0
- package/operator.mjs +72 -4
- package/orchestration.mjs +7 -2
- package/package.json +9 -5
- package/reducer.mjs +74 -5
package/mcp/mount.mjs
CHANGED
|
@@ -35,11 +35,12 @@ const ORCHESTRATOR_BIN = resolve(dirname(fileURLToPath(import.meta.url)), '../bi
|
|
|
35
35
|
* `rh2` server, whatever it holds, is neither replaced nor relied on). */
|
|
36
36
|
export const RH2_AGENT_SERVER = 'rh2-agent';
|
|
37
37
|
|
|
38
|
-
/** `rh2 mcp` run as
|
|
39
|
-
* its environment, so no config file carries the token. Null when no rh2-agent is
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
38
|
+
/** `rh2 mcp` run as the agent's installed RH2 principal: `apps mcp` puts the record's origin, organization and token in
|
|
39
|
+
* its environment, so no config file carries the token. Null when no rh2-agent record is the identity of `agentId` (its
|
|
40
|
+
* Teams id): a record is found by that id alone, so an agent sharing another's name is never mounted with its record. */
|
|
41
|
+
function rh2AgentServer(root, agentId, program) {
|
|
42
|
+
if (!installedAgentRecord(root, agentId)) return null;
|
|
43
|
+
return { command: program, args: [ORCHESTRATOR_BIN, 'apps', 'mcp', 'rh2-agent', '--root', root, '--agent', agentId] };
|
|
43
44
|
}
|
|
44
45
|
|
|
45
46
|
const slug = (surfaceKey) => (surfaceKey ? String(surfaceKey).replace(/[^A-Za-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '') || 'session' : 'session');
|
|
@@ -56,18 +57,18 @@ const slug = (surfaceKey) => (surfaceKey ? String(surfaceKey).replace(/[^A-Za-z0
|
|
|
56
57
|
* @param {string} [o.socketPath] defaults to `<root>/orchestrator.sock`
|
|
57
58
|
* @param {string} [o.serverPath] defaults to this package's `mcp/server.mjs`
|
|
58
59
|
* @param {string} [o.program] the interpreter, defaults to this node
|
|
59
|
-
* @param {string|null} [o.
|
|
60
|
-
* beside; null for a worker acting for an agent
|
|
60
|
+
* @param {string|null} [o.agentId] the Teams id of the agent whose own session this is, whose installed rh2-agent is
|
|
61
|
+
* mounted beside; null for a worker acting for an agent
|
|
61
62
|
* @returns {{door: string, ran: string, launch: object|undefined, mcp_servers: object[]|null, path: string|null}}
|
|
62
63
|
*/
|
|
63
|
-
export function mountOrchestratorMcp({ root, profile, harness, surfaceKey = null, launch, socketPath, serverPath = SERVER_PATH, program = process.execPath, vault = new Map(), task = null,
|
|
64
|
+
export function mountOrchestratorMcp({ root, profile, harness, surfaceKey = null, launch, socketPath, serverPath = SERVER_PATH, program = process.execPath, vault = new Map(), task = null, agentId = null }) {
|
|
64
65
|
const door = doorFor(harness);
|
|
65
66
|
// the profile's own servers (`.mcp.json`, the workers' form; docs/plans/hermes-compat.md row 4), mounted
|
|
66
67
|
// beside this one through the same door, `${VAR}` resolved from the profile's vault
|
|
67
68
|
const own = resolveServers(mcpServers(profile.dir), vault);
|
|
68
69
|
// an agent the RH2 organization declares acts there as its own principal, through its installed record (ADR 0017),
|
|
69
70
|
// not through whatever `rh2` the machine's user configured; a profile's own `rh2-agent` entry wins
|
|
70
|
-
const rh2 = own[RH2_AGENT_SERVER] ? null : rh2AgentServer(root,
|
|
71
|
+
const rh2 = own[RH2_AGENT_SERVER] ? null : rh2AgentServer(root, agentId, program);
|
|
71
72
|
if (rh2) own[RH2_AGENT_SERVER] = rh2;
|
|
72
73
|
const sock = socketPath ?? socketPathFor(root);
|
|
73
74
|
const home = join(profile.dir, harness);
|
package/ontology.d.ts
CHANGED
|
@@ -389,6 +389,21 @@ export type FireStatus =
|
|
|
389
389
|
/** The scheduler restarted under it. */
|
|
390
390
|
| 'unknown';
|
|
391
391
|
|
|
392
|
+
/** Observations a role can subscribe to, separate from a card's transition events. */
|
|
393
|
+
export type FleetEventKind =
|
|
394
|
+
/** A native session process first observed running. */
|
|
395
|
+
| 'session_started'
|
|
396
|
+
/** A known native session observed running in a new process incarnation. */
|
|
397
|
+
| 'session_resumed'
|
|
398
|
+
/** A native source reports a session process ended; unavailable sources are not ends. */
|
|
399
|
+
| 'session_ended'
|
|
400
|
+
/** More than one observed live process incarnation names the same native session. */
|
|
401
|
+
| 'duplicate_session_process'
|
|
402
|
+
/** A card's native session reports that it needs its user's input. */
|
|
403
|
+
| 'card_waiting_on_user'
|
|
404
|
+
/** A machine's health source reports a critical alarm. */
|
|
405
|
+
| 'machine_health_crit';
|
|
406
|
+
|
|
392
407
|
/** A conversation moved (or moving) to another surface — Hermes `handoff_*`. */
|
|
393
408
|
export interface Handoff {
|
|
394
409
|
/** The destination platform. */
|
|
@@ -721,6 +736,23 @@ export interface Reserve {
|
|
|
721
736
|
/** Unmodeled source fields, keyed by the source's own field name. */
|
|
722
737
|
export type Residue = Record<string, unknown>;
|
|
723
738
|
|
|
739
|
+
/**
|
|
740
|
+
* One role's fleet listeners, written under `workflow.yaml` `listeners.<role>`.
|
|
741
|
+
* Mailboxes are explicit, preferably stable `sc:<machine>:agent:<role>` addresses;
|
|
742
|
+
* session addresses remain valid destinations but do not follow a replacement main.
|
|
743
|
+
*/
|
|
744
|
+
export interface RoleFleetListener {
|
|
745
|
+
/** The observed kinds this role receives; a nonempty list. */
|
|
746
|
+
events: FleetEventKind[];
|
|
747
|
+
/** The role's full mail addresses; a nonempty list. Existing mail authority still applies. */
|
|
748
|
+
addresses: string[];
|
|
749
|
+
/**
|
|
750
|
+
* Normalized machine names to hear; absent means every machine the runtime may read.
|
|
751
|
+
* An empty list deliberately hears none.
|
|
752
|
+
*/
|
|
753
|
+
machines?: string[] | null;
|
|
754
|
+
}
|
|
755
|
+
|
|
724
756
|
/** One route. */
|
|
725
757
|
export interface Route {
|
|
726
758
|
/** Optional name. */
|
|
@@ -1102,6 +1134,11 @@ export interface Workflow {
|
|
|
1102
1134
|
params?: Record<string, unknown>;
|
|
1103
1135
|
/** Who acts on a card, each an expression naming a profile (`implementer: card.assignee`) or a person. */
|
|
1104
1136
|
roles?: Record<string, Expr>;
|
|
1137
|
+
/**
|
|
1138
|
+
* Opt-in fleet observations delivered as mail to each role's declared addresses. These are not card
|
|
1139
|
+
* transitions and grant no observation or machine authority. The manager is an implicit workflow role.
|
|
1140
|
+
*/
|
|
1141
|
+
listeners?: Record<string, RoleFleetListener>;
|
|
1105
1142
|
/** Where a new card starts: the first transition whose guard holds. */
|
|
1106
1143
|
initial: Transition[];
|
|
1107
1144
|
/** Every status, by name. */
|
package/operator.mjs
CHANGED
|
@@ -29,19 +29,21 @@
|
|
|
29
29
|
// look like a silent no-op.
|
|
30
30
|
|
|
31
31
|
import { existsSync, mkdirSync, renameSync } from 'node:fs';
|
|
32
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
32
33
|
import { join } from 'node:path';
|
|
33
34
|
import { keyFromString, parseSchedule } from './mcp/verbs.mjs';
|
|
34
35
|
import { emptyProfile, parseTarget, renderTarget, surfaceKeyString, decodeContextFrom } from './ir.mjs';
|
|
35
|
-
import { liveBinding } from './reducer.mjs';
|
|
36
|
+
import { launchKeyString, liveBinding } from './reducer.mjs';
|
|
36
37
|
import { compactStamp, gateFields, homeZone, nextRunAt, payloadError } from './cron.mjs';
|
|
37
38
|
import * as events from './events.mjs';
|
|
38
|
-
import { modelError } from './activation.mjs';
|
|
39
|
+
import { activationCwd, modelError } from './activation.mjs';
|
|
39
40
|
|
|
40
41
|
/** Every op the operator door answers, in §4.6 order. */
|
|
41
42
|
export const OPERATOR_OPS = Object.freeze([
|
|
42
43
|
'jobs.create', 'jobs.update', 'jobs.pause', 'jobs.resume', 'jobs.run', 'jobs.delete',
|
|
43
44
|
'profiles.create', 'profiles.delete',
|
|
44
45
|
'sessions.new', 'sessions.reset',
|
|
46
|
+
'sessions.launch', 'sessions.launch_status', 'sessions.launch_close',
|
|
45
47
|
'access.pair_approve',
|
|
46
48
|
'agents.fork',
|
|
47
49
|
]);
|
|
@@ -49,7 +51,7 @@ export const OPERATOR_OPS = Object.freeze([
|
|
|
49
51
|
/** Where a deleted profile folder is renamed to, relative to the root. */
|
|
50
52
|
export const TRASH_DIR = 'profiles/.trash';
|
|
51
53
|
|
|
52
|
-
class Refused extends Error {}
|
|
54
|
+
class Refused extends Error { constructor(message, code) { super(message); this.code = code; } }
|
|
53
55
|
|
|
54
56
|
/**
|
|
55
57
|
* Perform the reducer's `remove_profile_dir` effect (the ONLY effect this door
|
|
@@ -272,6 +274,9 @@ export async function applyOperator(state, op, args = {}, profileName = 'default
|
|
|
272
274
|
}
|
|
273
275
|
const send = async (verb, verbArgs, target = profileName) => {
|
|
274
276
|
const effects = (await dispatch(events.operator({ verb, profile: target, args: verbArgs, at: now }))) ?? [];
|
|
277
|
+
// An explicit refusal (a launch's key held by another launch, a spent budget, no worker) names its code.
|
|
278
|
+
const refused = effects.find((e) => e.type === 'refused');
|
|
279
|
+
if (refused) throw new Refused(refused.message, refused.code);
|
|
275
280
|
const refusal = refusalIn(effects);
|
|
276
281
|
if (refusal) throw new Refused(refusal);
|
|
277
282
|
return effects;
|
|
@@ -279,10 +284,58 @@ export async function applyOperator(state, op, args = {}, profileName = 'default
|
|
|
279
284
|
try {
|
|
280
285
|
return { ok: true, result: await run(state, profile, op, args ?? {}, now, send) };
|
|
281
286
|
} catch (error) {
|
|
282
|
-
return { ok: false, error: error.message };
|
|
287
|
+
return { ok: false, error: error.message, ...(error instanceof Refused && error.code ? { code: error.code } : {}) };
|
|
283
288
|
}
|
|
284
289
|
}
|
|
285
290
|
|
|
291
|
+
/** Where a launch stands: starting (no session yet), running (its native session named), or ended (and why); the agent
|
|
292
|
+
* it is for. A key no launch used reads as unknown. */
|
|
293
|
+
function launchView(state, key) {
|
|
294
|
+
const keyString = launchKeyString(String(key));
|
|
295
|
+
// The home's launches, whichever profile holds the key: a key another agent's launch holds reads as that launch.
|
|
296
|
+
const slots = Object.values(state.profiles).flatMap((profile) => Object.entries(profile.bindings).filter(([slot]) => slot === keyString || slot.startsWith(`${keyString}#`)).map(([, binding]) => binding));
|
|
297
|
+
const binding = slots.find((each) => !each.ended_at) ?? slots.sort((a, b) => String(b.ended_at).localeCompare(String(a.ended_at)))[0];
|
|
298
|
+
if (!binding) return endedLaunch(state, key, keyString) ?? { key, state: 'unknown' };
|
|
299
|
+
const native = binding.worker?.session_id && binding.worker.session_id !== `launch_${key}` ? binding.worker.session_id : null;
|
|
300
|
+
return { key, agent_id: binding.residue?.launch_agent ?? null, harness: binding.worker?.harness ?? null, native_session: native,
|
|
301
|
+
state: binding.ended_at ? 'ended' : native ? 'running' : 'starting', end_reason: binding.end_reason ?? null,
|
|
302
|
+
// the words it ended with (a start that failed says why), kept on the session record
|
|
303
|
+
end_detail: binding.residue?.end_detail ?? null, started_at: binding.started_at,
|
|
304
|
+
// the folder its worker runs in (activationCwd), which a Teams sync root must cover for Teams to capture it
|
|
305
|
+
cwd: state.profiles[binding.agent] ? activationCwd(state.profiles[binding.agent]) : null,
|
|
306
|
+
// the orchestrator's own last word from or to it, beside what Teams captured
|
|
307
|
+
last_activity_at: binding.last_activity_at ?? null };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* A launch no longer held in memory (a daemon restarted since it ended), read from the ended sessions the folder keeps:
|
|
312
|
+
* each profile's `state.db` table `supercode_ended_sessions` (codec/folder.rs write_ended), its latest row for the key.
|
|
313
|
+
* Read only. A store that cannot be read fails the read, never answers `unknown` (which a caller takes as no launch).
|
|
314
|
+
*/
|
|
315
|
+
function endedLaunch(state, key, keyString) {
|
|
316
|
+
let row = null;
|
|
317
|
+
for (const profile of Object.values(state.profiles)) {
|
|
318
|
+
const path = profile?.dir ? join(profile.dir, 'state.db') : null;
|
|
319
|
+
if (!path || !existsSync(path)) continue;
|
|
320
|
+
let db;
|
|
321
|
+
try {
|
|
322
|
+
db = new DatabaseSync(path, { readOnly: true });
|
|
323
|
+
if (!db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='supercode_ended_sessions'").get()) continue;
|
|
324
|
+
// the key's own slot, or one of its ended slots (`<key>#<started_at>[#n]`: '#' then '$' bounds that range)
|
|
325
|
+
for (const each of db.prepare('SELECT * FROM supercode_ended_sessions WHERE slot = ? OR (slot >= ? AND slot < ?)').all(keyString, `${keyString}#`, `${keyString}$`)) {
|
|
326
|
+
if (!row || String(each.ended_at) > String(row.ended_at)) row = each;
|
|
327
|
+
}
|
|
328
|
+
} catch (error) {
|
|
329
|
+
throw new Error(`launch ${key}: the ended sessions of profile ${profile.name} could not be read (${path}: ${error.message})`);
|
|
330
|
+
} finally { db?.close(); }
|
|
331
|
+
}
|
|
332
|
+
if (!row) return null;
|
|
333
|
+
const native = row.session_id && row.session_id !== `launch_${key}` ? row.session_id : null;
|
|
334
|
+
return { key, agent_id: row.launch_agent ?? null, harness: row.harness ?? null, native_session: native, state: 'ended',
|
|
335
|
+
end_reason: row.end_reason ?? null, end_detail: row.end_detail ?? null, started_at: row.started_at ?? null, cwd: null,
|
|
336
|
+
last_activity_at: null };
|
|
337
|
+
}
|
|
338
|
+
|
|
286
339
|
async function run(state, profile, op, args, now, send) {
|
|
287
340
|
switch (op) {
|
|
288
341
|
case 'jobs.create': {
|
|
@@ -362,6 +415,21 @@ async function run(state, profile, op, args, now, send) {
|
|
|
362
415
|
return { ran: `removed profile ${name}; its folder was moved into ${TRASH_DIR}/`, name, profile: null, deleted: true };
|
|
363
416
|
}
|
|
364
417
|
|
|
418
|
+
// A concurrent session of this profile's agent, started on request (README § The operator door): the bridge checks
|
|
419
|
+
// the agent and composes the first input; `launch_status` reads where it stands, `launch_close` ends it.
|
|
420
|
+
case 'sessions.launch': {
|
|
421
|
+
await send('session_launch', { key: args.key, agent_id: args.agent_id, input: args.input });
|
|
422
|
+
return { ran: `launch ${args.key} of agent ${args.agent_id} in profile ${profile.name}`, ...launchView(state, args.key) };
|
|
423
|
+
}
|
|
424
|
+
case 'sessions.launch_status': {
|
|
425
|
+
if (typeof args.key !== 'string' || !args.key) throw new Error('sessions.launch_status needs the launch key');
|
|
426
|
+
return { ran: `launch ${args.key}`, ...launchView(state, args.key) };
|
|
427
|
+
}
|
|
428
|
+
case 'sessions.launch_close': {
|
|
429
|
+
if (typeof args.key !== 'string' || !args.key) throw new Error('sessions.launch_close needs the launch key');
|
|
430
|
+
await send('session_launch_end', { key: args.key, reason: typeof args.reason === 'string' ? args.reason.slice(0, 200) : 'closed' });
|
|
431
|
+
return { ran: `closed launch ${args.key}`, ...launchView(state, args.key) };
|
|
432
|
+
}
|
|
365
433
|
case 'sessions.new':
|
|
366
434
|
case 'sessions.reset': {
|
|
367
435
|
const surface = String(args.surface ?? '').trim();
|
package/orchestration.mjs
CHANGED
|
@@ -163,9 +163,14 @@ export async function read(root) {
|
|
|
163
163
|
return (await through((d) => d.orchestrationLoad({ root }))).orchestration;
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
-
/**
|
|
166
|
+
/**
|
|
167
|
+
* `save(state, root)` — our folder, canonical and byte-stable: an artifact whose record has not changed keeps its bytes.
|
|
168
|
+
* Answers the entries the door could not read (`rejected`, each `{ collection, key, reason, kept_stored }`): each was
|
|
169
|
+
* written as the home last held it, or left out when it held none, and none refuses the rest of the save.
|
|
170
|
+
*/
|
|
167
171
|
export async function save(state, root = state.root) {
|
|
168
|
-
await through((d) => d.orchestrationSave({ root, orchestration: persistedView(state) }));
|
|
172
|
+
const saved = await through((d) => d.orchestrationSave({ root, orchestration: persistedView(state) }));
|
|
173
|
+
return { rejected: Array.isArray(saved?.rejected) ? saved.rejected : [] };
|
|
169
174
|
}
|
|
170
175
|
|
|
171
176
|
/** Another harness's home as a state value (no secret value; `vault_keys` names the `.env` keys it would need). */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-orchestrator",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.206",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The orchestrator runtime over the Volter Harness ontology: one typed operational model whose folder is its serialization, read and written through the harness orchestration doors (docs/ORCHESTRATOR-IR.md)",
|
|
6
6
|
"exports": {
|
|
@@ -13,6 +13,10 @@
|
|
|
13
13
|
"import": "./index.mjs"
|
|
14
14
|
},
|
|
15
15
|
"./bin": "./bin/orchestrator.mjs",
|
|
16
|
+
"./board-input": {
|
|
17
|
+
"types": "./board-input.d.ts",
|
|
18
|
+
"import": "./board-input.mjs"
|
|
19
|
+
},
|
|
16
20
|
"./apply": {
|
|
17
21
|
"types": "./apply/index.d.ts",
|
|
18
22
|
"import": "./apply/index.mjs"
|
|
@@ -28,10 +32,10 @@
|
|
|
28
32
|
"check-types": "node scripts/generate-types.mjs --check"
|
|
29
33
|
},
|
|
30
34
|
"dependencies": {
|
|
31
|
-
"@volter/supercode-teams": "^0.3.
|
|
32
|
-
"@volter/supercode-terminal": "^0.3.
|
|
33
|
-
"@volter/supercode-harness-sdk": "^0.3.
|
|
34
|
-
"@volter/teams": "0.2.
|
|
35
|
+
"@volter/supercode-teams": "^0.3.343",
|
|
36
|
+
"@volter/supercode-terminal": "^0.3.9",
|
|
37
|
+
"@volter/supercode-harness-sdk": "^0.3.103",
|
|
38
|
+
"@volter/teams": "0.2.95",
|
|
35
39
|
"@volter/ztrack": "^2.4.23",
|
|
36
40
|
"yaml": "^2.9.0"
|
|
37
41
|
},
|
package/reducer.mjs
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// effects (§5). It never performs I/O and imports no node module: the test
|
|
7
7
|
// suite asserts that statically. Time comes from the event's `at`.
|
|
8
8
|
|
|
9
|
-
import { surfaceKeyString, renderSurfaceKey, resolveRoute, sessionReset, homeChannel } from './ir.mjs';
|
|
9
|
+
import { END_REASONS, surfaceKeyString, renderSurfaceKey, resolveRoute, sessionReset, homeChannel } from './ir.mjs';
|
|
10
10
|
import { parseCommand, frame, REPLY } from './commands.mjs';
|
|
11
11
|
import { cronTick, gateResult, fireFinal, fireFailed, finishHostedFire, homeZone, isSynthetic, nextRunAt, scheduleError, unattendedRequest } from './cron.mjs';
|
|
12
12
|
import { handleWebhook } from './webhook.mjs';
|
|
@@ -88,12 +88,24 @@ export function accessPolicy(profile, platform) {
|
|
|
88
88
|
}
|
|
89
89
|
|
|
90
90
|
|
|
91
|
-
|
|
91
|
+
/**
|
|
92
|
+
* End the live binding at `keyString`. `reason` is why, as one of END_REASONS: the store refuses any other word, and a
|
|
93
|
+
* binding stored with one would refuse every later save of its home. `detail` is the free text of the cause (a worker's
|
|
94
|
+
* exit, a caller's own words), kept in the binding's residue as `end_detail`. A word that is not an end reason is never
|
|
95
|
+
* stored: the binding ends as `error`, the word goes to `end_detail`, and the log names the caller's mistake.
|
|
96
|
+
*/
|
|
97
|
+
export function endBinding(state, profileName, keyString, reason, now, effects, detail = null) {
|
|
92
98
|
const profile = state.profiles[profileName];
|
|
93
99
|
const b = profile.bindings[keyString];
|
|
94
100
|
if (!b || b.ended_at) return;
|
|
101
|
+
if (!END_REASONS.has(reason)) {
|
|
102
|
+
effects.push({ type: 'log', level: 'warn', message: `session ${keyString} in profile ${profileName}: ${JSON.stringify(String(reason)).slice(0, 200)} is not an end reason; it ends as error` });
|
|
103
|
+
detail = detail ?? String(reason);
|
|
104
|
+
reason = 'error';
|
|
105
|
+
}
|
|
95
106
|
b.ended_at = now;
|
|
96
107
|
b.end_reason = reason;
|
|
108
|
+
if (detail != null && detail !== '') b.residue = { ...(b.residue ?? {}), end_detail: String(detail).slice(0, 2000) };
|
|
97
109
|
if (b.residue) delete b.residue.runtime_id; // the lease is gone with the binding (§4.7)
|
|
98
110
|
// ended bindings keep their history under a started_at-qualified slot;
|
|
99
111
|
// two sessions ended within one clock instant get distinct slots
|
|
@@ -133,7 +145,13 @@ export function newObligation(profile, key, text, source, now) {
|
|
|
133
145
|
return o;
|
|
134
146
|
}
|
|
135
147
|
|
|
148
|
+
/** A session started on request (`sessions.launch`) answers no surface: it is read, and written to, where it was asked
|
|
149
|
+
* for (a Workplace Room reads its transcript and sends it input), so nothing of it is delivered here. */
|
|
150
|
+
export const LAUNCH_PLATFORM = 'launch';
|
|
151
|
+
export const launchKeyString = (launchKey) => surfaceKeyString({ platform: LAUNCH_PLATFORM, kind: 'session', chat_id: launchKey });
|
|
152
|
+
|
|
136
153
|
export function reply(state, profile, key, text, now, effects, source = { kind: 'turn' }) {
|
|
154
|
+
if (key?.platform === LAUNCH_PLATFORM) return null;
|
|
137
155
|
const o = newObligation(profile, key, text, source, now);
|
|
138
156
|
effects.push({ type: 'deliver', profile: profile.name, obligation_id: o.id });
|
|
139
157
|
return o;
|
|
@@ -555,6 +573,13 @@ function handleWorker(state, ev, effects) {
|
|
|
555
573
|
}
|
|
556
574
|
// a cron fire or a webhook turn has nobody to ask: answered now (§4.2)
|
|
557
575
|
if (unattendedRequest(state, profile, binding, ev.keyString, w, now, effects)) break;
|
|
576
|
+
// a launched session has no surface to ask on: the worker's unattended rule decides, as for a fire
|
|
577
|
+
if (binding.key?.platform === LAUNCH_PLATFORM) {
|
|
578
|
+
const approve = w.hermes?.verdict !== 'deny' && profile.worker?.permission?.unattended === 'approve';
|
|
579
|
+
effects.push({ type: 'respond', profile: profile.name, keyString: ev.keyString, request_id: String(w.id), response: permissionResponse(w.options, approve, `Denied: this session was started on request and nobody here approves its actions (worker.permission.unattended: ${approve ? 'approve' : 'deny'}).`) });
|
|
580
|
+
effects.push({ type: 'log', level: 'info', message: `launched session ${ev.keyString}: ${approve ? 'approved' : 'denied'} ${String(w.subject ?? 'a tool call').slice(0, 200)}` });
|
|
581
|
+
break;
|
|
582
|
+
}
|
|
558
583
|
rt.pending_requests[String(w.id)] = { profile: profile.name, keyString: ev.keyString, runtime_id: rt.leases[lid]?.runtime_id ?? null, asked_at: now, options: w.options ?? [] };
|
|
559
584
|
const options = (w.options ?? []).map((o, i) => `${i + 1}. ${o.name}`).join('\n');
|
|
560
585
|
reply(state, profile, binding.key, `Permission needed: ${w.subject ?? 'the worker wants to act'}\nReply with a number:\n${options}`, now, effects);
|
|
@@ -565,10 +590,15 @@ function handleWorker(state, ev, effects) {
|
|
|
565
590
|
delete rt.turns[lid];
|
|
566
591
|
delete rt.starting[lid];
|
|
567
592
|
releaseHandovers(rt, lid, now, effects);
|
|
568
|
-
const
|
|
593
|
+
const why = String(w.message ?? w.reason ?? 'worker exited');
|
|
594
|
+
// Why it ended is kept where it can be read after the fact, not only answered to whoever was asking: the agent's
|
|
595
|
+
// log, and the session's own record (its residue, which every codec keeps), beside its end_reason. A start that
|
|
596
|
+
// failed (loop.mjs startRuntime: `start failed: …`) left both empty.
|
|
597
|
+
effects.push({ type: 'log', level: w.kind === 'error' ? 'warn' : 'info', message: `session ${ev.keyString} in profile ${profile.name} ended: ${why}` });
|
|
598
|
+
const synthetic = binding ? fireFailed(state, profile, binding, why, now, effects) : false;
|
|
569
599
|
if (binding) {
|
|
570
|
-
endBinding(state, profile.name, ev.keyString, 'error', now, effects);
|
|
571
|
-
if (!synthetic) reply(state, profile, binding.key, `Session ended: ${
|
|
600
|
+
endBinding(state, profile.name, ev.keyString, 'error', now, effects, why);
|
|
601
|
+
if (!synthetic) reply(state, profile, binding.key, `Session ended: ${why}.`, now, effects);
|
|
572
602
|
} else {
|
|
573
603
|
delete rt.leases[lid];
|
|
574
604
|
}
|
|
@@ -778,6 +808,45 @@ function handleOperator(state, ev, effects) {
|
|
|
778
808
|
effects.push({ type: 'save' });
|
|
779
809
|
break;
|
|
780
810
|
}
|
|
811
|
+
// A concurrent session of this profile's agent, started on request (operator `sessions.launch`): its own surface
|
|
812
|
+
// keyed by the launch key, its first input queued behind the start. A key names one agent's launch for good.
|
|
813
|
+
// Its refusals are `refused` effects with a code, which the operator door answers as the op's error.
|
|
814
|
+
case 'session_launch': {
|
|
815
|
+
const { key, agent_id: agentId, input } = ev.args ?? {};
|
|
816
|
+
if (typeof key !== 'string' || !/^[A-Za-z0-9._:-]{1,120}$/.test(key) || typeof agentId !== 'string' || !agentId || typeof input !== 'string' || !input.trim()) {
|
|
817
|
+
effects.push({ type: 'refused', code: 'invalid_request', message: 'session_launch: a launch key, the agent id and a first input are required' });
|
|
818
|
+
break;
|
|
819
|
+
}
|
|
820
|
+
const keyString = launchKeyString(key);
|
|
821
|
+
// A launch key is the home's: whichever profile's launch holds it, it names that launch for good.
|
|
822
|
+
const holder = Object.values(state.profiles).flatMap((each) => Object.entries(each.bindings).filter(([slot]) => slot === keyString || slot.startsWith(`${keyString}#`)).map(([, binding]) => ({ binding, profile: each.name })))[0];
|
|
823
|
+
if (holder && (holder.binding.residue?.launch_agent !== agentId || holder.profile !== profile.name)) { effects.push({ type: 'refused', code: 'conflict', message: `session_launch: launch key ${key} belongs to another launch in this home` }); break; }
|
|
824
|
+
if (holder) { effects.push({ type: 'save' }); break; } // the same launch, asked again: nothing new starts
|
|
825
|
+
if (!profile.worker) { effects.push({ type: 'refused', code: 'conflict', message: `session_launch: profile ${profile.name} has no worker configured` }); break; }
|
|
826
|
+
const refused = budgetRefusal(state, profile, profile.name, ev.at);
|
|
827
|
+
if (refused) { effects.push({ type: 'refused', code: 'budget', message: `session_launch: not started, ${refused}` }); break; }
|
|
828
|
+
profile.bindings[keyString] = {
|
|
829
|
+
key: { platform: LAUNCH_PLATFORM, kind: 'session', chat_id: key }, agent: profile.name,
|
|
830
|
+
worker: { harness: profile.worker.harness, session_id: `launch_${key}`, locator: null },
|
|
831
|
+
started_at: ev.at, last_activity_at: ev.at, ended_at: null, end_reason: null, recurrence: null,
|
|
832
|
+
residue: { launch: key, launch_agent: agentId },
|
|
833
|
+
};
|
|
834
|
+
const rt = ensureRuntime(state);
|
|
835
|
+
(rt.queues[leaseId(profile.name, keyString)] ??= []).push({ text: input, at: ev.at });
|
|
836
|
+
rt.starting[leaseId(profile.name, keyString)] = true;
|
|
837
|
+
effects.push({ type: 'runtime_start', profile: profile.name, keyString, worker: profile.worker, prompt: null, agent: profile.name, concurrent: true });
|
|
838
|
+
effects.push({ type: 'save' });
|
|
839
|
+
break;
|
|
840
|
+
}
|
|
841
|
+
case 'session_launch_end': {
|
|
842
|
+
const keyString = launchKeyString(String(ev.args?.key ?? ''));
|
|
843
|
+
if (!liveBinding(profile, keyString)) { effects.push({ type: 'save' }); break; }
|
|
844
|
+
// A launch its caller closed is left (an END_REASONS word); the caller's own words are its detail, never its reason.
|
|
845
|
+
const words = typeof ev.args?.reason === 'string' ? ev.args.reason : null;
|
|
846
|
+
endBinding(state, profile.name, keyString, 'left', ev.at, effects, words);
|
|
847
|
+
effects.push({ type: 'save' });
|
|
848
|
+
break;
|
|
849
|
+
}
|
|
781
850
|
case 'sessions_new':
|
|
782
851
|
case 'sessions_reset': {
|
|
783
852
|
const keyString = ev.args.keyString;
|