@volter/supercode-orchestrator 0.5.64 → 0.5.66
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/adapters/jira-comments.mjs +157 -0
- package/bin/agent-channel.mjs +44 -3
- package/board/dispatch.mjs +43 -2
- package/index.mjs +1 -0
- package/package.json +2 -2
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// A Jira site's ticket comments as a channel (company RFC 0026 decision 3): the client's comments on the tickets a JQL
|
|
2
|
+
// finds reach an agent's mailbox through `AgentMailboxBridge`, and the agent's answers are posted back as comments on
|
|
3
|
+
// the same ticket. The three-method trait every adapter has: `connect(emit)` / `send(target, content)` / `close()`.
|
|
4
|
+
//
|
|
5
|
+
// A ticket is a thread. Its root is the first comment this adapter saw on it; every later comment on that ticket replies
|
|
6
|
+
// to it, so a ticket's conversation is one mailbox thread. Comments are paged past the page a search embeds (a long
|
|
7
|
+
// thread is never cut). What was on a ticket before the first connect is the baseline and is not filed; what the
|
|
8
|
+
// credential's own account posts (the agent's answers) is `is_me`. A ticket that comes into the JQL's scope later is
|
|
9
|
+
// baselined the same way when it is first seen. Seen comments and roots are kept in `statePath`.
|
|
10
|
+
import { readFileSync, writeFileSync, renameSync, mkdirSync } from 'node:fs';
|
|
11
|
+
import { dirname } from 'node:path';
|
|
12
|
+
|
|
13
|
+
const DEFAULT_POLL_MS = 60_000;
|
|
14
|
+
|
|
15
|
+
/** ADF (Jira's rich text) to plain text: paragraphs and line breaks kept, marks dropped. */
|
|
16
|
+
export function adfText(node) {
|
|
17
|
+
if (node == null) return '';
|
|
18
|
+
if (typeof node === 'string') return node;
|
|
19
|
+
if (Array.isArray(node)) return node.map(adfText).join('');
|
|
20
|
+
if (node.type === 'text') return String(node.text ?? '');
|
|
21
|
+
if (node.type === 'hardBreak') return '\n';
|
|
22
|
+
if (node.type === 'mention') return String(node.attrs?.text ?? '@someone');
|
|
23
|
+
const inner = adfText(node.content ?? []);
|
|
24
|
+
return ['paragraph', 'heading', 'listItem', 'codeBlock', 'blockquote'].includes(node.type) ? `${inner}\n` : inner;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Plain text to ADF: one paragraph per line. */
|
|
28
|
+
export function textAdf(text) {
|
|
29
|
+
return { type: 'doc', version: 1, content: String(text).split('\n').map((line) => ({ type: 'paragraph', content: line ? [{ type: 'text', text: line }] : [] })) };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class JiraCommentsAdapter {
|
|
33
|
+
/**
|
|
34
|
+
* @param {object} o
|
|
35
|
+
* @param {string} o.site the site (`https://<site>.atlassian.net`, or a Jira twin's URL)
|
|
36
|
+
* @param {string} o.jql which tickets are this channel's
|
|
37
|
+
* @param {string} o.authorization the request's Authorization header (Basic email:token, or Bearer)
|
|
38
|
+
*/
|
|
39
|
+
constructor({ site, jql, authorization, statePath = null, pollMs = DEFAULT_POLL_MS, fetchImpl = fetch, log = () => {}, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) }) {
|
|
40
|
+
if (!site || !jql || !authorization) throw new Error('JiraCommentsAdapter needs a site, a JQL and an authorization');
|
|
41
|
+
this.platform = 'jira';
|
|
42
|
+
this.site = site.replace(/\/+$/, '');
|
|
43
|
+
this.jql = jql;
|
|
44
|
+
this.authorization = authorization;
|
|
45
|
+
this.statePath = statePath;
|
|
46
|
+
this.pollMs = pollMs;
|
|
47
|
+
this.fetch = fetchImpl;
|
|
48
|
+
this.log = log;
|
|
49
|
+
this.sleep = sleep;
|
|
50
|
+
this.running = false;
|
|
51
|
+
/** ticket key -> { root: comment id | null, seen: comment ids } */
|
|
52
|
+
this.tickets = {};
|
|
53
|
+
this.baselined = false;
|
|
54
|
+
this.me = null;
|
|
55
|
+
if (statePath) {
|
|
56
|
+
try {
|
|
57
|
+
const state = JSON.parse(readFileSync(statePath, 'utf8'));
|
|
58
|
+
this.tickets = state.tickets ?? {};
|
|
59
|
+
this.baselined = state.baselined === true;
|
|
60
|
+
} catch { /* a first run */ }
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
save() {
|
|
65
|
+
if (!this.statePath) return;
|
|
66
|
+
mkdirSync(dirname(this.statePath), { recursive: true });
|
|
67
|
+
const tmp = `${this.statePath}.tmp`;
|
|
68
|
+
writeFileSync(tmp, JSON.stringify({ baselined: this.baselined, tickets: this.tickets }));
|
|
69
|
+
renameSync(tmp, this.statePath);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async call(method, path, body) {
|
|
73
|
+
const res = await this.fetch(`${this.site}${path}`, {
|
|
74
|
+
method, headers: { authorization: this.authorization, accept: 'application/json', ...(body ? { 'content-type': 'application/json' } : {}) },
|
|
75
|
+
...(body ? { body: JSON.stringify(body) } : {}),
|
|
76
|
+
});
|
|
77
|
+
const text = await res.text();
|
|
78
|
+
if (res.status >= 300) throw new Error(`jira: ${method} ${path.split('?')[0]} answered ${res.status}${text ? ` — ${text.slice(0, 200)}` : ''}`);
|
|
79
|
+
return text ? JSON.parse(text) : {};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Every comment of a ticket, in order, paged past the page a search embeds. */
|
|
83
|
+
async comments(key) {
|
|
84
|
+
const all = [];
|
|
85
|
+
for (let startAt = 0; ; ) {
|
|
86
|
+
const page = await this.call('GET', `/rest/api/3/issue/${encodeURIComponent(key)}/comment?startAt=${startAt}&maxResults=100&orderBy=created`);
|
|
87
|
+
const rows = page.comments ?? [];
|
|
88
|
+
all.push(...rows);
|
|
89
|
+
startAt += rows.length;
|
|
90
|
+
if (rows.length === 0 || startAt >= (page.total ?? 0)) return all;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The tickets the JQL finds now. */
|
|
95
|
+
async keys() {
|
|
96
|
+
const keys = [];
|
|
97
|
+
for (let token; ; ) {
|
|
98
|
+
const params = new URLSearchParams({ jql: this.jql, fields: 'summary', maxResults: '100', ...(token ? { nextPageToken: token } : {}) });
|
|
99
|
+
const page = await this.call('GET', `/rest/api/3/search/jql?${params}`);
|
|
100
|
+
keys.push(...(page.issues ?? []).map((issue) => String(issue.key)));
|
|
101
|
+
if (!page.nextPageToken || (page.issues ?? []).length === 0) return keys;
|
|
102
|
+
token = page.nextPageToken;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async poll() {
|
|
107
|
+
for (const key of await this.keys()) {
|
|
108
|
+
// A ticket new to this channel (the first poll, or one that comes into the JQL's scope later) is its own baseline:
|
|
109
|
+
// what was on it before is not filed, only what is posted after.
|
|
110
|
+
const fresh = !this.tickets[key];
|
|
111
|
+
const ticket = (this.tickets[key] ??= { root: null, seen: [] });
|
|
112
|
+
const seen = new Set(ticket.seen);
|
|
113
|
+
for (const c of await this.comments(key)) {
|
|
114
|
+
const id = String(c.id ?? '');
|
|
115
|
+
if (!id || seen.has(id)) continue;
|
|
116
|
+
seen.add(id);
|
|
117
|
+
if (!this.baselined || fresh) continue;
|
|
118
|
+
const mine = this.me !== null && c.author?.accountId === this.me;
|
|
119
|
+
const root = ticket.root;
|
|
120
|
+
if (root === null && !mine) ticket.root = id;
|
|
121
|
+
this.emit?.({
|
|
122
|
+
key: { platform: 'jira', chat_id: key, thread_id: root ?? undefined, kind: 'group' },
|
|
123
|
+
sender: c.author?.accountId ?? null, sender_name: c.author?.displayName ?? null, is_me: mine, is_bot: false,
|
|
124
|
+
message_id: id, at: c.created ?? null, text: adfText(c.body).trim(),
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
ticket.seen = [...seen];
|
|
128
|
+
}
|
|
129
|
+
this.baselined = true;
|
|
130
|
+
this.save();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async connect(emit) {
|
|
134
|
+
this.emit = emit;
|
|
135
|
+
this.me = (await this.call('GET', '/rest/api/3/myself')).accountId ?? null;
|
|
136
|
+
this.running = true;
|
|
137
|
+
await this.poll().catch((error) => this.log('warn', `jira: ${error.message}`));
|
|
138
|
+
this.loop = (async () => {
|
|
139
|
+
while (this.running) {
|
|
140
|
+
await this.sleep(this.pollMs);
|
|
141
|
+
if (!this.running) break;
|
|
142
|
+
await this.poll().catch((error) => this.log('warn', `jira: ${error.message}`));
|
|
143
|
+
}
|
|
144
|
+
})();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** An answer, posted as a comment on the ticket its thread is about. */
|
|
148
|
+
async send(target, content) {
|
|
149
|
+
const key = String(target.chat_id);
|
|
150
|
+
const posted = await this.call('POST', `/rest/api/3/issue/${encodeURIComponent(key)}/comment`, { body: textAdf(content.text ?? '') });
|
|
151
|
+
const id = String(posted.id ?? '');
|
|
152
|
+
if (id) { const ticket = (this.tickets[key] ??= { root: null, seen: [] }); ticket.seen.push(id); this.save(); }
|
|
153
|
+
return { message_id: id || null };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async close() { this.running = false; await this.loop; }
|
|
157
|
+
}
|
package/bin/agent-channel.mjs
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
// `supercode-orchestrator agent-channel --agent <name>`: an agent's account Room, carried to its one mailbox
|
|
2
|
-
// (docs/adr/0008-agent-mailbox.md, decisions 24-25).
|
|
2
|
+
// (docs/adr/0008-agent-mailbox.md, decisions 24-25). With `--platform slack` or `--platform jira` it carries another
|
|
3
|
+
// channel to that mailbox instead, through the same bridge (company RFC 0026 decision 3: an engagement's Slack channel
|
|
4
|
+
// and its client's ticket comments are channels of its account manager):
|
|
5
|
+
// --platform slack --slack-channel <id>[,<id>] the Chat SDK's Slack (Socket Mode), SLACK_BOT_TOKEN and SLACK_APP_TOKEN
|
|
6
|
+
// from the environment; only the named channels' lines are filed
|
|
7
|
+
// --platform jira --site <url> --jql <jql> the tickets' comments (adapters/jira-comments.mjs), JIRA_EMAIL and
|
|
8
|
+
// JIRA_API_TOKEN (or JIRA_TOKEN) from the environment
|
|
9
|
+
// The RH2 account Room: the Room sits on the organization's floor (the RH2 bot's
|
|
3
10
|
// organization), with the agent as its one participant: every line a person posts there is filed as mail to the agent,
|
|
4
11
|
// and every answer is posted back anchored at its thread's root, by `AgentMailboxBridge` over the RH2 adapter.
|
|
5
12
|
//
|
|
@@ -24,17 +31,50 @@ import { join } from 'node:path';
|
|
|
24
31
|
import { SupercodeHarnessClient } from '@volter/supercode-harness-sdk';
|
|
25
32
|
import { Rh2Adapter } from '../adapters/rh2.mjs';
|
|
26
33
|
import { AgentMailboxBridge } from '../adapters/agent-mailbox.mjs';
|
|
34
|
+
import { JiraCommentsAdapter } from '../adapters/jira-comments.mjs';
|
|
35
|
+
import { ChatSdkAdapter } from '../adapters/chat-sdk.mjs';
|
|
27
36
|
import { installedApps } from '../installed-apps.mjs';
|
|
28
37
|
|
|
29
38
|
const { values } = parseArgs({
|
|
30
39
|
args: process.argv.slice(3),
|
|
31
|
-
options: { agent: { type: 'string' }, root: { type: 'string' }, room: { type: 'string' }, 'room-key': { type: 'string' }, 'room-name': { type: 'string' }, principal: { type: 'string' }, state: { type: 'string' }, help: { type: 'boolean' } },
|
|
40
|
+
options: { agent: { type: 'string' }, root: { type: 'string' }, room: { type: 'string' }, 'room-key': { type: 'string' }, 'room-name': { type: 'string' }, principal: { type: 'string' }, state: { type: 'string' }, platform: { type: 'string' }, site: { type: 'string' }, jql: { type: 'string' }, 'slack-channel': { type: 'string' }, help: { type: 'boolean' } },
|
|
32
41
|
});
|
|
33
42
|
if (values.help || !values.agent) {
|
|
34
|
-
console.log('supercode-orchestrator agent-channel --agent <name> [--root <home>] [--room <id> | --room-key <key> --principal <rh2 principal id> [--room-name <name>]] [--state <file>]');
|
|
43
|
+
console.log('supercode-orchestrator agent-channel --agent <name> [--root <home>] [--room <id> | --room-key <key> --principal <rh2 principal id> [--room-name <name>]] [--state <file>]\n supercode-orchestrator agent-channel --agent <name> --platform slack --slack-channel <id>[,<id>] [--state <file>]\n supercode-orchestrator agent-channel --agent <name> --platform jira --site <url> --jql <jql> [--state <file>]');
|
|
35
44
|
process.exit(values.help ? 0 : 2);
|
|
36
45
|
}
|
|
37
46
|
const log = (level, line) => (level === 'debug' ? undefined : console.error(`${new Date().toISOString()} ${level} ${line}`));
|
|
47
|
+
if (values.platform && values.platform !== 'rh2') {
|
|
48
|
+
const statePath = (name) => values.state ?? join(homedir(), '.config', 'supercode', 'agent-channels', `${values.agent}.${name}.json`);
|
|
49
|
+
let adapter;
|
|
50
|
+
if (values.platform === 'jira') {
|
|
51
|
+
if (!values.site || !values.jql) { console.error('agent-channel: --platform jira needs --site and --jql'); process.exit(2); }
|
|
52
|
+
const authorization = process.env.JIRA_EMAIL && process.env.JIRA_API_TOKEN
|
|
53
|
+
? `Basic ${Buffer.from(`${process.env.JIRA_EMAIL}:${process.env.JIRA_API_TOKEN}`).toString('base64')}`
|
|
54
|
+
: process.env.JIRA_TOKEN ? `Bearer ${process.env.JIRA_TOKEN}` : null;
|
|
55
|
+
if (!authorization) { console.error('agent-channel: --platform jira needs JIRA_EMAIL and JIRA_API_TOKEN (or JIRA_TOKEN) in its environment'); process.exit(2); }
|
|
56
|
+
adapter = new JiraCommentsAdapter({ site: values.site, jql: values.jql, authorization, statePath: `${statePath('jira')}.comments`, log });
|
|
57
|
+
} else if (values.platform === 'slack') {
|
|
58
|
+
const channels = new Set(String(values['slack-channel'] ?? '').split(',').map((c) => c.trim()).filter(Boolean));
|
|
59
|
+
if (!channels.size) { console.error('agent-channel: --platform slack needs --slack-channel <id>: an agent is carried only the channels named'); process.exit(2); }
|
|
60
|
+
if (!process.env.SLACK_BOT_TOKEN || !process.env.SLACK_APP_TOKEN) { console.error('agent-channel: --platform slack needs SLACK_BOT_TOKEN and SLACK_APP_TOKEN in its environment'); process.exit(2); }
|
|
61
|
+
// The Slack SDK adapter is built here, in Socket Mode, from this process's own environment: the wrapper's own path
|
|
62
|
+
// reads a profile's .env, and a channel process has no profile folder. SLACK_API_URL (a twin's) is honoured.
|
|
63
|
+
let createSlackAdapter;
|
|
64
|
+
try { ({ createSlackAdapter } = await import('@chat-adapter/slack')); } catch (error) { console.error(`agent-channel: @chat-adapter/slack is not installed (${error.message})`); process.exit(2); }
|
|
65
|
+
const sdkAdapter = createSlackAdapter({ botToken: process.env.SLACK_BOT_TOKEN, appToken: process.env.SLACK_APP_TOKEN, mode: 'socket', ...(process.env.SLACK_API_URL ? { apiUrl: process.env.SLACK_API_URL } : {}) });
|
|
66
|
+
const slack = new ChatSdkAdapter({ platform: 'slack', profile: values.agent, channel: { enabled: true, extra: {} }, sdkAdapter, log });
|
|
67
|
+
// Only the named channels' lines reach the mailbox; answers go back through the same adapter.
|
|
68
|
+
adapter = { platform: 'slack', connect: (emit) => slack.connect((event) => { if (channels.has(String(event.key?.chat_id))) emit(event); }), send: (target, content) => slack.send(target, content), close: () => slack.close?.() };
|
|
69
|
+
} else { console.error(`agent-channel: --platform is rh2, slack or jira (got ${values.platform})`); process.exit(2); }
|
|
70
|
+
const client = new SupercodeHarnessClient(process.env.SUPERCODE_BIN ? { command: process.env.SUPERCODE_BIN } : {});
|
|
71
|
+
const bridge = new AgentMailboxBridge({ adapter, client, agent: values.agent, platform: values.platform, fromName: `${values.platform}-${values.agent}`, log, statePath: statePath(values.platform) });
|
|
72
|
+
await bridge.start();
|
|
73
|
+
log('info', `${values.agent}: ${values.platform} is carried to its mailbox`);
|
|
74
|
+
const stop = async () => { await bridge.stop(); await adapter.close?.(); await client.close?.(); process.exit(0); };
|
|
75
|
+
process.on('SIGTERM', stop);
|
|
76
|
+
process.on('SIGINT', stop);
|
|
77
|
+
} else {
|
|
38
78
|
const root = values.root ?? process.env.HERMES_HOME;
|
|
39
79
|
const apps = root ? installedApps(root).filter((app) => app.profile === values.agent) : [];
|
|
40
80
|
const installation = apps.find((app) => app.app === 'rh2-agent') ?? apps.find((app) => app.app === 'room-chat');
|
|
@@ -114,3 +154,4 @@ const rebinding = setInterval(() => { void bindDm().catch((error) => log('warn',
|
|
|
114
154
|
const stop = async () => { clearInterval(rebinding); await bridge.stop(); await client.close?.(); process.exit(0); };
|
|
115
155
|
process.on('SIGTERM', stop);
|
|
116
156
|
process.on('SIGINT', stop);
|
|
157
|
+
}
|
package/board/dispatch.mjs
CHANGED
|
@@ -850,6 +850,42 @@ export async function startCard(board, id, { roles = ['manager'], lanes, setting
|
|
|
850
850
|
|
|
851
851
|
/** Reconcile durable close intent and ended runs, including old unverified closed_at records.
|
|
852
852
|
* Reads and process I/O stay outside the writer. An active successor owns its pane until it ends. */
|
|
853
|
+
/** Why the close of an ended run's live session waits, or null when nothing would be cut. Read last, just before the
|
|
854
|
+
* kill and outside any write: a pane's by the harness session under its process (whatever session it is: an id the run
|
|
855
|
+
* never learned, a harness its record does not name), a session in no pane by its own id. Only a session read idle, or
|
|
856
|
+
* stopped, or no harness session in the pane at all, is closed; working, waiting on input, a turn the harness does not
|
|
857
|
+
* publish (unknown) and a reading that fails all wait a round. The card's move never does. */
|
|
858
|
+
// The programs a harness pane runs in its foreground: Claude Code's, Codex's, and node (the daemon's Codex pane host,
|
|
859
|
+
// a harness installed through npm). A pane running one of these runs a harness session, read or not.
|
|
860
|
+
const HARNESS_COMMANDS = new Set(['claude', 'claude.exe', 'codex', 'codex.exe', 'node']);
|
|
861
|
+
function turnAtClose(session, pane = null) {
|
|
862
|
+
const machine = session.machine ?? localMachine();
|
|
863
|
+
let activity;
|
|
864
|
+
if (pane?.pid) {
|
|
865
|
+
const r = doorOn(machine, 'harness.v1.sessions.activity_under', { pids: [pane.pid] }, { timeout: NATIVE_DISCOVERY_MS });
|
|
866
|
+
const page = r.code === 0 ? lastJson(r.stdout) : null;
|
|
867
|
+
if (!Array.isArray(page?.activities)) return `the session in pane ${session.pane} could not be read just now (${(r.stderr || r.stdout).trim().slice(-160)})`;
|
|
868
|
+
activity = page.activities.find((item) => item.pid === pane.pid)?.activity ?? null;
|
|
869
|
+
// No session read under the pane is no absence (a registry it could not read, another config home, a failed process
|
|
870
|
+
// read): the pane's own foreground program says whether a harness runs there. Only a pane running none is closed.
|
|
871
|
+
if (!activity) {
|
|
872
|
+
const command = String(pane.command ?? '').split(/[\\/]/).pop().toLowerCase();
|
|
873
|
+
return command && !HARNESS_COMMANDS.has(command) ? null : `pane ${session.pane} runs ${command || 'a program it does not name'}, whose session could not be read`;
|
|
874
|
+
}
|
|
875
|
+
} else if (pane) {
|
|
876
|
+
return `pane ${session.pane} names no process to read`;
|
|
877
|
+
} else {
|
|
878
|
+
if (!session.session_id) return 'its session is not known yet';
|
|
879
|
+
try { activity = nativeReading(discoverNative(machine, session.harness ?? 'claude-code', session.session_id), session).row?.activity ?? null; }
|
|
880
|
+
catch (error) { return `its session could not be read just now (${String(error.message).slice(0, 160)})`; }
|
|
881
|
+
if (!activity) return `its session ${addressOf(session)} is not in its machine's discovery`;
|
|
882
|
+
}
|
|
883
|
+
if (activity.presence && activity.presence !== 'running') return null;
|
|
884
|
+
if (activity.turn === 'idle') return null;
|
|
885
|
+
const who = activity.session_id ? `sc:${normalMachine(machine)}:${activity.harness ?? session.harness}:${activity.session_id}` : addressOf(session);
|
|
886
|
+
return `its session ${who} is in a turn (${activity.turn ?? 'unknown'}); it closes once the turn ends`;
|
|
887
|
+
}
|
|
888
|
+
|
|
853
889
|
export function closeRequested(board, report, { taskId = null, caller = null, ttl = null, fleet = null } = {}) {
|
|
854
890
|
const rows = view(board, (db) => db.prepare(`SELECT id, task_id, ended_at, metadata FROM task_runs
|
|
855
891
|
WHERE metadata IS NOT NULL ${taskId ? 'AND task_id = ?' : ''}`).all(...(taskId ? [taskId] : [])));
|
|
@@ -941,13 +977,14 @@ export function closeRequested(board, report, { taskId = null, caller = null, tt
|
|
|
941
977
|
if (reading.alive) {
|
|
942
978
|
const protectedNow = active.some(r => addressOf(json(r.metadata, {})?.supercode) === addressOf(session));
|
|
943
979
|
if (protectedNow) throw new Error('close deferred: native session is owned by an unended run');
|
|
980
|
+
if (row.ended_at) { const inTurn = turnAtClose(session); if (inTurn) throw new Error(`close deferred: ${inTurn}`); }
|
|
944
981
|
stopSession(board, session);
|
|
945
982
|
}
|
|
946
983
|
} else {
|
|
947
984
|
if (!panes.has(machine)) {
|
|
948
985
|
const r = doorOn(machine, 'harness.v1.panes.list',{}, { timeout: 60_000, as: boardAddress(board.label) });
|
|
949
986
|
const list = r.code === 0 ? json(r.stdout.trim(), null) : null;
|
|
950
|
-
panes.set(machine, Array.isArray(list) ? { live: new
|
|
987
|
+
panes.set(machine, Array.isArray(list) ? { live: new Map(list.filter(p => !p.dead).map((p) => [p.pane, { pid: p.pid ?? null, command: p.activeCommand ?? null }])) } : { error: `cannot read panes on ${machine}: ${(r.stderr || r.stdout).trim().slice(-300)}` });
|
|
951
988
|
}
|
|
952
989
|
const observed = panes.get(machine);
|
|
953
990
|
if (observed.error) throw new Error(`close deferred: ${observed.error}`); // an unread pane list is no verdict on the pane
|
|
@@ -955,6 +992,9 @@ export function closeRequested(board, report, { taskId = null, caller = null, tt
|
|
|
955
992
|
const protectedNow = view(board, (db) => db.prepare(`SELECT metadata FROM task_runs r WHERE r.ended_at IS NULL OR EXISTS (SELECT 1 FROM tasks t WHERE t.current_run_id = r.id)`).all())
|
|
956
993
|
.some((r) => { const s = json(r.metadata, {})?.supercode; return s?.pane === session.pane && normalMachine(s.machine) === normalMachine(session.machine); });
|
|
957
994
|
if (protectedNow) throw new Error(`close deferred: pane ${session.pane} is owned by an unended run`);
|
|
995
|
+
// An ended run's session is closed only between its turns, read last, after the claim and the pane list (a pane
|
|
996
|
+
// already gone is settled above with no reading; t_f34710bc's coder was cut mid-proof at 15:01:13Z, 2026-10-04).
|
|
997
|
+
if (row.ended_at) { const inTurn = turnAtClose(session, observed.live.get(session.pane)); if (inTurn) throw new Error(`close deferred: ${inTurn}`); }
|
|
958
998
|
stopSession(board, session); observed.live.delete(session.pane);
|
|
959
999
|
}
|
|
960
1000
|
}
|
|
@@ -2426,7 +2466,8 @@ function turnInFlight(board, id, fleet) {
|
|
|
2426
2466
|
if (!session?.session_id || session.surface === 'headless' || session.close_requested) return null;
|
|
2427
2467
|
let turn = fleet.sessions?.find((x) => x.address === addressOf(session))?.turn ?? null;
|
|
2428
2468
|
if (turn == null && session.surface === 'native') try { turn = nativeReading(nativeSessions(session.machine, session.harness ?? 'claude-code', session.session_id), session).row?.activity?.turn ?? null; } catch { /* unread: waits on nothing */ }
|
|
2429
|
-
|
|
2469
|
+
// a turn stopped on a permission or a question (needs_input) is a turn not finished, as one still working is
|
|
2470
|
+
return ['working', 'needs_input'].includes(turn) ? `its session ${addressOf(session)} is in a turn (${turn}); the review is requested once the turn ends` : null;
|
|
2430
2471
|
}
|
|
2431
2472
|
|
|
2432
2473
|
async function activatePullRequestReviews(board, entry, report, effects, { only = null, dry = false, fleet = null } = {}) {
|
package/index.mjs
CHANGED
|
@@ -23,6 +23,7 @@ export { ChatSdkAdapter, toInbound, toAttachment, chatType, CHAT_SDK_VERSION, CH
|
|
|
23
23
|
export { ApiServerAdapter, DEFAULT_HOST as API_SERVER_HOST, DEFAULT_PORT as API_SERVER_PORT } from './adapters/api-server.mjs';
|
|
24
24
|
export { Rh2Adapter } from './adapters/rh2.mjs';
|
|
25
25
|
export { AgentMailboxBridge } from './adapters/agent-mailbox.mjs';
|
|
26
|
+
export { JiraCommentsAdapter, adfText, textAdf } from './adapters/jira-comments.mjs';
|
|
26
27
|
export { TOOLS, TOOL_NAMES, SERVER_NAME, PROTOCOL_VERSION } from './mcp/tools.mjs';
|
|
27
28
|
export { mountOrchestratorMcp, doorFor, MCP_DOORS, SERVER_PATH } from './mcp/mount.mjs';
|
|
28
29
|
export { startBridge } from './mcp/bridge.mjs';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-orchestrator",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.66",
|
|
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": {
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
27
|
"@volter/supercode-harness-sdk": "^0.3.60",
|
|
28
|
-
"@volter/teams": "0.2.
|
|
28
|
+
"@volter/teams": "0.2.20",
|
|
29
29
|
"@volter/ztrack": "^2.4.23",
|
|
30
30
|
"yaml": "^2.9.0"
|
|
31
31
|
},
|