@runuai/host 0.9.5 → 0.9.7
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/db/migrations/0012_agent_session_attach_key.sql +4 -0
- package/db/migrations/meta/_journal.json +8 -1
- package/db/schema.ts +4 -0
- package/lib/agent-cli.ts +50 -7
- package/lib/agents/claude.ts +274 -19
- package/lib/agents/dispatch.ts +78 -0
- package/lib/agents/mode.ts +7 -0
- package/lib/agents/transport.ts +12 -1
- package/lib/agents/types.ts +11 -0
- package/lib/orchestrator.ts +427 -126
- package/package.json +1 -1
- package/scripts/agent/task-up.sh +16 -2
- package/src/index.ts +15 -1
- package/src/main.ts +18 -1
- package/src/protocol.ts +41 -0
package/lib/orchestrator.ts
CHANGED
|
@@ -73,9 +73,22 @@ import type {
|
|
|
73
73
|
ChannelHuman,
|
|
74
74
|
HostEvent,
|
|
75
75
|
} from "../src/protocol";
|
|
76
|
+
import {
|
|
77
|
+
MAX_AGENT_ID_CHARS,
|
|
78
|
+
MAX_SECRETARY_DISPATCH_ID_CHARS,
|
|
79
|
+
SECRETARY_TYPED_DISPATCH_PROTOCOL_FEATURE,
|
|
80
|
+
} from "../src/protocol";
|
|
76
81
|
|
|
77
82
|
export type HostEventSubscriber = (event: HostEvent) => void;
|
|
78
83
|
|
|
84
|
+
// Rolling upgrade fence: v0 Secretary runners were launched under the
|
|
85
|
+
// communicator tool-denial profile and may survive a host restart. Durable
|
|
86
|
+
// attach is otherwise keyed only by task+agent, so give the new normal-tools
|
|
87
|
+
// policy an explicit generation and replace any legacy null-key runner once.
|
|
88
|
+
const SECRETARY_SESSION_ATTACH_KEY = "secretary-normal-tools-v1";
|
|
89
|
+
const RESTRICTED_SECRETARY_SESSION_ATTACH_KEY =
|
|
90
|
+
"secretary-communicator-v1";
|
|
91
|
+
|
|
79
92
|
// ---------------------------------------------------------------------------
|
|
80
93
|
// Channel — one task's live conversation.
|
|
81
94
|
// ---------------------------------------------------------------------------
|
|
@@ -154,6 +167,11 @@ interface Channel {
|
|
|
154
167
|
/** Agents with a delivered turn that has not reached a terminal event yet.
|
|
155
168
|
* Unlike openTurns, this covers thinking/tool/permission time before text. */
|
|
156
169
|
activeTurns: Map<string, number>;
|
|
170
|
+
/** Crew replies waiting for the designated Secretary's current turn
|
|
171
|
+
* boundary. Adapters do not share one mid-turn input contract, so only this
|
|
172
|
+
* cross-lane role is serialized at the host boundary; open mode keeps its
|
|
173
|
+
* legacy immediate-concurrent delivery behavior. */
|
|
174
|
+
pendingPrompts: Map<string, string[]>;
|
|
157
175
|
/** Agents whose current turn was interrupted (ESC) — their next
|
|
158
176
|
* turn_complete is flagged `aborted` so the cloud DISCARDS the buffered
|
|
159
177
|
* half-turn instead of delivering it to @-mentioned peers. */
|
|
@@ -330,14 +348,12 @@ export class Orchestrator {
|
|
|
330
348
|
// -- channel lifecycle ----------------------------------------------------
|
|
331
349
|
|
|
332
350
|
registerChannelSpec(spec: ChannelEnsureInput): void {
|
|
333
|
-
// Host-side fail closed. The cloud validates this too, but
|
|
334
|
-
//
|
|
335
|
-
//
|
|
336
|
-
// make `executionProfileFor` return undefined and spawn a full-access
|
|
337
|
-
// process in a task explicitly marked Secretary.
|
|
351
|
+
// Host-side fail closed. The cloud validates this too, but routing must not
|
|
352
|
+
// depend on every caller spelling the id correctly: an unmatched
|
|
353
|
+
// designation would otherwise create Secretary mode with no secretary.
|
|
338
354
|
if (!hasValidSecretarySelection(spec)) {
|
|
339
355
|
throw new Error(
|
|
340
|
-
"secretary mode requires exactly one roster agent matching secretaryAgentId",
|
|
356
|
+
"secretary mode requires typed-dispatch negotiation and exactly one roster agent matching secretaryAgentId",
|
|
341
357
|
);
|
|
342
358
|
}
|
|
343
359
|
const channel = this.channels.get(spec.taskId);
|
|
@@ -352,7 +368,7 @@ export class Orchestrator {
|
|
|
352
368
|
// Open→Secretary rewrite cannot be applied as ordinary roster refresh:
|
|
353
369
|
// the designated agent may already be a durable full-access process.
|
|
354
370
|
// Changing these fields without recycling that exact session would
|
|
355
|
-
// advertise a
|
|
371
|
+
// advertise a different role/routing boundary than the running process.
|
|
356
372
|
throw new Error(
|
|
357
373
|
"a live channel cannot change secretary mode or designation",
|
|
358
374
|
);
|
|
@@ -531,6 +547,7 @@ export class Orchestrator {
|
|
|
531
547
|
closed: false,
|
|
532
548
|
openTurns: new Set(),
|
|
533
549
|
activeTurns: new Map(),
|
|
550
|
+
pendingPrompts: new Map(),
|
|
534
551
|
interrupted: new Set(),
|
|
535
552
|
respawns: new Map(),
|
|
536
553
|
respawnLastAt: new Map(),
|
|
@@ -616,6 +633,19 @@ export class Orchestrator {
|
|
|
616
633
|
if (!this.isActiveChannel(channel)) return false;
|
|
617
634
|
await this.reconcileSessions(channel);
|
|
618
635
|
}
|
|
636
|
+
// The initial-start promise stays memoized after a healthy boot. A later
|
|
637
|
+
// fatal exit can therefore leave the designated Secretary missing even
|
|
638
|
+
// when reconciliation could not recreate it (for example because CLI
|
|
639
|
+
// materialization now fails). Secretary mode must report that degraded
|
|
640
|
+
// state rather than reuse the old `true` forever.
|
|
641
|
+
if (
|
|
642
|
+
ready &&
|
|
643
|
+
channel.mode === "secretary" &&
|
|
644
|
+
(!channel.secretaryAgentId ||
|
|
645
|
+
!channel.sessions.has(channel.secretaryAgentId))
|
|
646
|
+
) {
|
|
647
|
+
return false;
|
|
648
|
+
}
|
|
619
649
|
return ready;
|
|
620
650
|
}
|
|
621
651
|
|
|
@@ -1213,7 +1243,21 @@ export class Orchestrator {
|
|
|
1213
1243
|
|
|
1214
1244
|
const apiUrl = apiUrlFromCloudUrl(env.UAI_CLOUD_URL);
|
|
1215
1245
|
const cliSecret = loadTaskCliSecret(channel.taskId);
|
|
1216
|
-
writeAgentCli(
|
|
1246
|
+
const cliWritten = writeAgentCli(
|
|
1247
|
+
channel.taskId,
|
|
1248
|
+
roster,
|
|
1249
|
+
apiUrl,
|
|
1250
|
+
channel.mode === "secretary",
|
|
1251
|
+
);
|
|
1252
|
+
if (
|
|
1253
|
+
this.hasPromptLevelSecretary(channel) &&
|
|
1254
|
+
(!cliWritten || !task.ownerUserId || !apiUrl || !cliSecret)
|
|
1255
|
+
) {
|
|
1256
|
+
console.warn(
|
|
1257
|
+
`[orchestrator] ${channel.taskId}: prompt-level Secretary CLI/token materialization failed`,
|
|
1258
|
+
);
|
|
1259
|
+
return;
|
|
1260
|
+
}
|
|
1217
1261
|
|
|
1218
1262
|
for (const agent of missing) {
|
|
1219
1263
|
if (!this.isActiveChannel(channel)) return;
|
|
@@ -1229,6 +1273,10 @@ export class Orchestrator {
|
|
|
1229
1273
|
containerName: channel.containerName,
|
|
1230
1274
|
systemPreamble: channel.preambles.get(agent.id) ?? "",
|
|
1231
1275
|
executionProfile: this.executionProfileFor(channel, agent.id),
|
|
1276
|
+
attachCompatibilityKey: this.attachCompatibilityKeyFor(
|
|
1277
|
+
channel,
|
|
1278
|
+
agent.id,
|
|
1279
|
+
),
|
|
1232
1280
|
agentEnv: this.accountAgentEnv(
|
|
1233
1281
|
channel,
|
|
1234
1282
|
agent,
|
|
@@ -1238,11 +1286,13 @@ export class Orchestrator {
|
|
|
1238
1286
|
task.ownerUserId,
|
|
1239
1287
|
apiUrl,
|
|
1240
1288
|
cliSecret,
|
|
1289
|
+
this.isPromptLevelSecretary(channel, agent.id),
|
|
1241
1290
|
),
|
|
1242
1291
|
),
|
|
1243
1292
|
},
|
|
1244
1293
|
);
|
|
1245
1294
|
if (!session) return;
|
|
1295
|
+
this.startNextPendingPrompt(channel, agent.id);
|
|
1246
1296
|
}
|
|
1247
1297
|
} finally {
|
|
1248
1298
|
for (const agent of missing) channel.spawning.delete(agent.id);
|
|
@@ -1252,19 +1302,49 @@ export class Orchestrator {
|
|
|
1252
1302
|
}
|
|
1253
1303
|
}
|
|
1254
1304
|
|
|
1255
|
-
/**
|
|
1256
|
-
* The browser stores only the task-level secretary identity. Derive the
|
|
1257
|
-
* restricted adapter profile here so a roster entry can never self-assert a
|
|
1258
|
-
* weaker/stronger execution policy through its JSON payload.
|
|
1259
|
-
*/
|
|
1305
|
+
/** Reserved seam for a future explicit restricted-role control. */
|
|
1260
1306
|
private executionProfileFor(
|
|
1307
|
+
_channel: Channel,
|
|
1308
|
+
_agentId: string,
|
|
1309
|
+
): "communicator" | undefined {
|
|
1310
|
+
// Secretary is an organisational role, not a sandbox. The communicator
|
|
1311
|
+
// profile remains implemented by adapters for a future explicit opt-in,
|
|
1312
|
+
// but no task gets tool denial merely by selecting Secretary mode.
|
|
1313
|
+
return undefined;
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
private attachCompatibilityKeyFor(
|
|
1261
1317
|
channel: Channel,
|
|
1262
1318
|
agentId: string,
|
|
1263
|
-
):
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1319
|
+
): string | undefined {
|
|
1320
|
+
if (!this.isSecretary(channel, agentId)) return undefined;
|
|
1321
|
+
// Key the durable runner to the policy actually passed to the adapter.
|
|
1322
|
+
// The restricted profile is dormant today, but giving it the normal-tools
|
|
1323
|
+
// generation would make a future opt-out able to attach a runner launched
|
|
1324
|
+
// under tool denial.
|
|
1325
|
+
return this.executionProfileFor(channel, agentId) === "communicator"
|
|
1326
|
+
? RESTRICTED_SECRETARY_SESSION_ATTACH_KEY
|
|
1327
|
+
: SECRETARY_SESSION_ATTACH_KEY;
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
private isSecretary(channel: Channel, agentId: string): boolean {
|
|
1331
|
+
return (
|
|
1332
|
+
channel.mode === "secretary" && channel.secretaryAgentId === agentId
|
|
1333
|
+
);
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
private isPromptLevelSecretary(channel: Channel, agentId: string): boolean {
|
|
1337
|
+
return (
|
|
1338
|
+
this.isSecretary(channel, agentId) &&
|
|
1339
|
+
this.executionProfileFor(channel, agentId) === undefined
|
|
1340
|
+
);
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
private hasPromptLevelSecretary(channel: Channel): boolean {
|
|
1344
|
+
return Boolean(
|
|
1345
|
+
channel.secretaryAgentId &&
|
|
1346
|
+
this.isPromptLevelSecretary(channel, channel.secretaryAgentId),
|
|
1347
|
+
);
|
|
1268
1348
|
}
|
|
1269
1349
|
|
|
1270
1350
|
/**
|
|
@@ -1448,7 +1528,21 @@ export class Orchestrator {
|
|
|
1448
1528
|
// are actually enforced. Best-effort, host-side.
|
|
1449
1529
|
const apiUrl = apiUrlFromCloudUrl(env.UAI_CLOUD_URL);
|
|
1450
1530
|
const cliSecret = loadTaskCliSecret(channel.taskId);
|
|
1451
|
-
writeAgentCli(
|
|
1531
|
+
const cliWritten = writeAgentCli(
|
|
1532
|
+
channel.taskId,
|
|
1533
|
+
channel.roster,
|
|
1534
|
+
apiUrl,
|
|
1535
|
+
channel.mode === "secretary",
|
|
1536
|
+
);
|
|
1537
|
+
if (
|
|
1538
|
+
this.hasPromptLevelSecretary(channel) &&
|
|
1539
|
+
(!cliWritten || !task.ownerUserId || !apiUrl || !cliSecret)
|
|
1540
|
+
) {
|
|
1541
|
+
console.warn(
|
|
1542
|
+
`[orchestrator] ${channel.taskId}: prompt-level Secretary CLI/token materialization failed`,
|
|
1543
|
+
);
|
|
1544
|
+
return false;
|
|
1545
|
+
}
|
|
1452
1546
|
|
|
1453
1547
|
for (const agent of initialRoster) {
|
|
1454
1548
|
if (!this.isActiveChannel(channel)) return false;
|
|
@@ -1468,6 +1562,10 @@ export class Orchestrator {
|
|
|
1468
1562
|
containerName: channel.containerName,
|
|
1469
1563
|
systemPreamble: channel.preambles.get(agent.id) ?? "",
|
|
1470
1564
|
executionProfile: this.executionProfileFor(channel, agent.id),
|
|
1565
|
+
attachCompatibilityKey: this.attachCompatibilityKeyFor(
|
|
1566
|
+
channel,
|
|
1567
|
+
agent.id,
|
|
1568
|
+
),
|
|
1471
1569
|
agentEnv: this.accountAgentEnv(
|
|
1472
1570
|
channel,
|
|
1473
1571
|
agent,
|
|
@@ -1477,11 +1575,13 @@ export class Orchestrator {
|
|
|
1477
1575
|
task.ownerUserId,
|
|
1478
1576
|
apiUrl,
|
|
1479
1577
|
cliSecret,
|
|
1578
|
+
this.isPromptLevelSecretary(channel, agent.id),
|
|
1480
1579
|
),
|
|
1481
1580
|
),
|
|
1482
1581
|
},
|
|
1483
1582
|
);
|
|
1484
1583
|
if (!session) return false;
|
|
1584
|
+
this.startNextPendingPrompt(channel, agent.id);
|
|
1485
1585
|
} finally {
|
|
1486
1586
|
channel.spawning.delete(agent.id);
|
|
1487
1587
|
}
|
|
@@ -1503,9 +1603,9 @@ export class Orchestrator {
|
|
|
1503
1603
|
* calls this so agents auto-start the moment the task is running,
|
|
1504
1604
|
* without the human having to send a message first.
|
|
1505
1605
|
*/
|
|
1506
|
-
async ensureStarted(taskId: string): Promise<
|
|
1606
|
+
async ensureStarted(taskId: string): Promise<boolean> {
|
|
1507
1607
|
const channel = await this.getOrCreateChannel(taskId);
|
|
1508
|
-
|
|
1608
|
+
return channel ? this.ensureSessions(channel) : false;
|
|
1509
1609
|
}
|
|
1510
1610
|
|
|
1511
1611
|
private incrementActiveTurns(channel: Channel, agentId: string): void {
|
|
@@ -1534,6 +1634,77 @@ export class Orchestrator {
|
|
|
1534
1634
|
return remaining;
|
|
1535
1635
|
}
|
|
1536
1636
|
|
|
1637
|
+
/** Retire turn-local state owned by a runner generation before replacement. */
|
|
1638
|
+
private retireRunnerGeneration(channel: Channel, agentId: string): void {
|
|
1639
|
+
channel.activeTurns.delete(agentId);
|
|
1640
|
+
channel.openTurns.delete(agentId);
|
|
1641
|
+
channel.interrupted.delete(agentId);
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
private startPrompt(
|
|
1645
|
+
channel: Channel,
|
|
1646
|
+
agentId: string,
|
|
1647
|
+
session: AgentSession,
|
|
1648
|
+
prompt: string,
|
|
1649
|
+
): void {
|
|
1650
|
+
// ADR-076: this is the prompt actually in flight. A queued follow-up must
|
|
1651
|
+
// not replace it or account rotation would replay the wrong turn.
|
|
1652
|
+
channel.lastPrompt.set(agentId, prompt);
|
|
1653
|
+
this.incrementActiveTurns(channel, agentId);
|
|
1654
|
+
void session.send(prompt).catch((err: unknown) => {
|
|
1655
|
+
if (channel.sessions.get(agentId) === session) {
|
|
1656
|
+
const remaining = this.decrementActiveTurns(channel, agentId);
|
|
1657
|
+
if (
|
|
1658
|
+
remaining === 0 &&
|
|
1659
|
+
channel.lastPrompt.get(agentId) === prompt
|
|
1660
|
+
) {
|
|
1661
|
+
channel.lastPrompt.delete(agentId);
|
|
1662
|
+
}
|
|
1663
|
+
this.startNextPendingPrompt(channel, agentId);
|
|
1664
|
+
}
|
|
1665
|
+
console.warn(
|
|
1666
|
+
`[orchestrator] ${channel.taskId}/${agentId}: send failed: ${
|
|
1667
|
+
err instanceof Error ? err.message : String(err)
|
|
1668
|
+
}`,
|
|
1669
|
+
);
|
|
1670
|
+
});
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
/** Start at most one queued prompt after the previous turn is terminal. */
|
|
1674
|
+
private startNextPendingPrompt(channel: Channel, agentId: string): boolean {
|
|
1675
|
+
if (
|
|
1676
|
+
!this.isActiveChannel(channel) ||
|
|
1677
|
+
!this.isSecretary(channel, agentId) ||
|
|
1678
|
+
(channel.activeTurns.get(agentId) ?? 0) > 0
|
|
1679
|
+
) {
|
|
1680
|
+
return false;
|
|
1681
|
+
}
|
|
1682
|
+
const queue = channel.pendingPrompts.get(agentId);
|
|
1683
|
+
const session = channel.sessions.get(agentId);
|
|
1684
|
+
if (!queue || queue.length === 0 || !session) return false;
|
|
1685
|
+
const prompt = queue.shift()!;
|
|
1686
|
+
if (queue.length === 0) channel.pendingPrompts.delete(agentId);
|
|
1687
|
+
this.startPrompt(channel, agentId, session, prompt);
|
|
1688
|
+
return true;
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
/** A dead Secretary session must not strand already-accepted crew replies. */
|
|
1692
|
+
private reconcilePendingSecretary(channel: Channel, agentId: string): void {
|
|
1693
|
+
if (
|
|
1694
|
+
!this.isSecretary(channel, agentId) ||
|
|
1695
|
+
(channel.pendingPrompts.get(agentId)?.length ?? 0) === 0
|
|
1696
|
+
) {
|
|
1697
|
+
return;
|
|
1698
|
+
}
|
|
1699
|
+
void this.reconcileSessions(channel).catch((err: unknown) => {
|
|
1700
|
+
console.warn(
|
|
1701
|
+
`[orchestrator] ${channel.taskId}/${agentId}: pending-prompt reconcile failed: ${
|
|
1702
|
+
err instanceof Error ? err.message : String(err)
|
|
1703
|
+
}`,
|
|
1704
|
+
);
|
|
1705
|
+
});
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1537
1708
|
// -- inbound: a human (or routed peer) message ----------------------------
|
|
1538
1709
|
|
|
1539
1710
|
/**
|
|
@@ -1563,20 +1734,23 @@ export class Orchestrator {
|
|
|
1563
1734
|
if (!session) return { ok: false, error: `no such agent: ${agentId}` };
|
|
1564
1735
|
|
|
1565
1736
|
const prompt = rewriteAttachmentRefs(text);
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
this.
|
|
1737
|
+
if (this.isSecretary(channel, agentId)) {
|
|
1738
|
+
const queue = channel.pendingPrompts.get(agentId) ?? [];
|
|
1739
|
+
if ((channel.activeTurns.get(agentId) ?? 0) > 0 || queue.length > 0) {
|
|
1740
|
+
queue.push(prompt);
|
|
1741
|
+
channel.pendingPrompts.set(agentId, queue);
|
|
1742
|
+
// A replacement session may have bound after the queue was observed
|
|
1743
|
+
// but before this delivery. Preserve FIFO by starting the oldest item.
|
|
1744
|
+
this.startNextPendingPrompt(channel, agentId);
|
|
1745
|
+
} else {
|
|
1746
|
+
this.startPrompt(channel, agentId, session, prompt);
|
|
1573
1747
|
}
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
);
|
|
1579
|
-
}
|
|
1748
|
+
} else {
|
|
1749
|
+
// Preserve the legacy open-room contract: adapters receive concurrent
|
|
1750
|
+
// deliveries immediately. Only the designated Secretary needs the hard
|
|
1751
|
+
// turn boundary because crew replies can arrive during its dispatch turn.
|
|
1752
|
+
this.startPrompt(channel, agentId, session, prompt);
|
|
1753
|
+
}
|
|
1580
1754
|
return { ok: true };
|
|
1581
1755
|
}
|
|
1582
1756
|
|
|
@@ -1663,6 +1837,24 @@ export class Orchestrator {
|
|
|
1663
1837
|
});
|
|
1664
1838
|
break;
|
|
1665
1839
|
}
|
|
1840
|
+
case "dispatch": {
|
|
1841
|
+
this.markActiveTurn(channel, agentId);
|
|
1842
|
+
this.emitHost({
|
|
1843
|
+
kind: "agent.dispatch",
|
|
1844
|
+
taskId: channel.taskId,
|
|
1845
|
+
agentId,
|
|
1846
|
+
// Optional trace metadata must never make the action undeliverable.
|
|
1847
|
+
// Provider tool ids have no useful product-level length guarantee;
|
|
1848
|
+
// omit an oversized one while preserving the dispatch itself.
|
|
1849
|
+
...(event.dispatchId.length > 0 &&
|
|
1850
|
+
event.dispatchId.length <= MAX_SECRETARY_DISPATCH_ID_CHARS
|
|
1851
|
+
? { dispatchId: event.dispatchId }
|
|
1852
|
+
: {}),
|
|
1853
|
+
recipients: event.recipients,
|
|
1854
|
+
instruction: event.instruction,
|
|
1855
|
+
});
|
|
1856
|
+
break;
|
|
1857
|
+
}
|
|
1666
1858
|
case "permission_request": {
|
|
1667
1859
|
this.markActiveTurn(channel, agentId);
|
|
1668
1860
|
this.emitHost({
|
|
@@ -1692,9 +1884,7 @@ export class Orchestrator {
|
|
|
1692
1884
|
case "error": {
|
|
1693
1885
|
// The turn died with the session — drop its turn-state flags so a
|
|
1694
1886
|
// respawned session starts clean.
|
|
1695
|
-
|
|
1696
|
-
channel.openTurns.delete(agentId);
|
|
1697
|
-
channel.interrupted.delete(agentId);
|
|
1887
|
+
this.retireRunnerGeneration(channel, agentId);
|
|
1698
1888
|
// Claude under load (especially Docker Desktop macOS) occasionally
|
|
1699
1889
|
// unlinks ~/.claude.json mid-write during atomic config rewrites,
|
|
1700
1890
|
// and a concurrent claude spawn lands during the gap and exits 0
|
|
@@ -1725,9 +1915,14 @@ export class Orchestrator {
|
|
|
1725
1915
|
re.test(event.message),
|
|
1726
1916
|
);
|
|
1727
1917
|
if (isClaude && configMissing) {
|
|
1918
|
+
// This recovery path deliberately starts the next queued turn rather
|
|
1919
|
+
// than replaying the failed one. Do not leave it looking in-flight to
|
|
1920
|
+
// a later account refresh.
|
|
1921
|
+
channel.lastPrompt.delete(agentId);
|
|
1728
1922
|
await this.recoverClaudeAgent(channel, agentId);
|
|
1729
1923
|
break;
|
|
1730
1924
|
}
|
|
1925
|
+
channel.lastPrompt.delete(agentId);
|
|
1731
1926
|
this.emitHost({
|
|
1732
1927
|
kind: "agent.exit",
|
|
1733
1928
|
taskId: channel.taskId,
|
|
@@ -1749,6 +1944,7 @@ export class Orchestrator {
|
|
|
1749
1944
|
channel.sessions.delete(agentId);
|
|
1750
1945
|
channel.browserStaleSessions.delete(agentId);
|
|
1751
1946
|
channel.browserPendingStaleSessions.delete(agentId);
|
|
1947
|
+
this.reconcilePendingSecretary(channel, agentId);
|
|
1752
1948
|
break;
|
|
1753
1949
|
}
|
|
1754
1950
|
case "turn_complete": {
|
|
@@ -1769,10 +1965,14 @@ export class Orchestrator {
|
|
|
1769
1965
|
aborted,
|
|
1770
1966
|
usage: event.usage,
|
|
1771
1967
|
});
|
|
1968
|
+
if (remainingTurns === 0) {
|
|
1969
|
+
channel.lastPrompt.delete(agentId);
|
|
1970
|
+
this.startNextPendingPrompt(channel, agentId);
|
|
1971
|
+
}
|
|
1772
1972
|
// A config repair never interrupts a turn. The stale id remains queued
|
|
1773
1973
|
// until this exact boundary, then reconcile closes and replaces it.
|
|
1774
1974
|
if (
|
|
1775
|
-
|
|
1975
|
+
(channel.activeTurns.get(agentId) ?? 0) === 0 &&
|
|
1776
1976
|
channel.browserStaleSessions.has(agentId)
|
|
1777
1977
|
) {
|
|
1778
1978
|
void this.reconcileSessions(channel).catch((err: unknown) => {
|
|
@@ -1788,14 +1988,14 @@ export class Orchestrator {
|
|
|
1788
1988
|
case "exit":
|
|
1789
1989
|
// Same zombie hazard as the error path — a session whose process
|
|
1790
1990
|
// ended (even cleanly) can never carry another turn.
|
|
1791
|
-
|
|
1792
|
-
channel.
|
|
1793
|
-
channel.interrupted.delete(agentId);
|
|
1991
|
+
this.retireRunnerGeneration(channel, agentId);
|
|
1992
|
+
channel.lastPrompt.delete(agentId);
|
|
1794
1993
|
channel.respawns.set(agentId, (channel.respawns.get(agentId) ?? 0) + 1);
|
|
1795
1994
|
channel.respawnLastAt.set(agentId, Date.now());
|
|
1796
1995
|
channel.sessions.delete(agentId);
|
|
1797
1996
|
channel.browserStaleSessions.delete(agentId);
|
|
1798
1997
|
channel.browserPendingStaleSessions.delete(agentId);
|
|
1998
|
+
this.reconcilePendingSecretary(channel, agentId);
|
|
1799
1999
|
break;
|
|
1800
2000
|
}
|
|
1801
2001
|
}
|
|
@@ -1845,6 +2045,7 @@ export class Orchestrator {
|
|
|
1845
2045
|
channel.sessions.delete(agentId);
|
|
1846
2046
|
channel.browserStaleSessions.delete(agentId);
|
|
1847
2047
|
channel.browserPendingStaleSessions.delete(agentId);
|
|
2048
|
+
this.retireRunnerGeneration(channel, agentId);
|
|
1848
2049
|
if (old) {
|
|
1849
2050
|
try {
|
|
1850
2051
|
await old.close();
|
|
@@ -1866,6 +2067,7 @@ export class Orchestrator {
|
|
|
1866
2067
|
task.ownerUserId,
|
|
1867
2068
|
apiUrl,
|
|
1868
2069
|
cliSecret,
|
|
2070
|
+
this.isPromptLevelSecretary(channel, agent.id),
|
|
1869
2071
|
);
|
|
1870
2072
|
const boundAccountId = channel.accountByAgent.get(agentId);
|
|
1871
2073
|
const boundAccount = boundAccountId
|
|
@@ -1879,11 +2081,16 @@ export class Orchestrator {
|
|
|
1879
2081
|
containerName: channel.containerName,
|
|
1880
2082
|
systemPreamble: channel.preambles.get(agentId) ?? "",
|
|
1881
2083
|
executionProfile: this.executionProfileFor(channel, agentId),
|
|
2084
|
+
attachCompatibilityKey: this.attachCompatibilityKeyFor(
|
|
2085
|
+
channel,
|
|
2086
|
+
agentId,
|
|
2087
|
+
),
|
|
1882
2088
|
agentEnv: boundAccount
|
|
1883
2089
|
? { ...base, ...boundAccount.execEnv }
|
|
1884
2090
|
: this.accountAgentEnv(channel, agent, base),
|
|
1885
2091
|
});
|
|
1886
2092
|
if (!session) return;
|
|
2093
|
+
this.startNextPendingPrompt(channel, agentId);
|
|
1887
2094
|
|
|
1888
2095
|
const message = restored
|
|
1889
2096
|
? `${agentId} restarted — config file was missing in the container, restored from the host.`
|
|
@@ -1896,6 +2103,9 @@ export class Orchestrator {
|
|
|
1896
2103
|
});
|
|
1897
2104
|
} finally {
|
|
1898
2105
|
channel.spawning.delete(agentId);
|
|
2106
|
+
if (!channel.sessions.has(agentId)) {
|
|
2107
|
+
this.reconcilePendingSecretary(channel, agentId);
|
|
2108
|
+
}
|
|
1899
2109
|
// A concurrent config writer can mark the newly-bound recovery stale
|
|
1900
2110
|
// while this spawn slot is still held. Reconciliation skips spawning
|
|
1901
2111
|
// ids by design, so release must explicitly give that mark another turn.
|
|
@@ -1957,6 +2167,7 @@ export class Orchestrator {
|
|
|
1957
2167
|
channel.sessions.delete(agentId);
|
|
1958
2168
|
channel.browserStaleSessions.delete(agentId);
|
|
1959
2169
|
channel.browserPendingStaleSessions.delete(agentId);
|
|
2170
|
+
this.retireRunnerGeneration(channel, agentId);
|
|
1960
2171
|
if (old) {
|
|
1961
2172
|
try {
|
|
1962
2173
|
await old.close();
|
|
@@ -1985,6 +2196,7 @@ export class Orchestrator {
|
|
|
1985
2196
|
task.ownerUserId,
|
|
1986
2197
|
apiUrl,
|
|
1987
2198
|
cliSecret,
|
|
2199
|
+
this.isPromptLevelSecretary(channel, agent.id),
|
|
1988
2200
|
);
|
|
1989
2201
|
channel.accountByAgent.set(agentId, next.id);
|
|
1990
2202
|
noteEngineAccountUsed(next.id);
|
|
@@ -1998,6 +2210,10 @@ export class Orchestrator {
|
|
|
1998
2210
|
containerName: channel.containerName,
|
|
1999
2211
|
systemPreamble: channel.preambles.get(agentId) ?? "",
|
|
2000
2212
|
executionProfile: this.executionProfileFor(channel, agentId),
|
|
2213
|
+
attachCompatibilityKey: this.attachCompatibilityKeyFor(
|
|
2214
|
+
channel,
|
|
2215
|
+
agentId,
|
|
2216
|
+
),
|
|
2001
2217
|
agentEnv: { ...base, ...next.execEnv },
|
|
2002
2218
|
},
|
|
2003
2219
|
);
|
|
@@ -2011,20 +2227,15 @@ export class Orchestrator {
|
|
|
2011
2227
|
// Re-deliver the in-flight prompt so the interrupted turn resumes.
|
|
2012
2228
|
const prompt = channel.lastPrompt.get(agentId);
|
|
2013
2229
|
if (prompt) {
|
|
2014
|
-
this.
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
this.decrementActiveTurns(channel, agentId);
|
|
2018
|
-
}
|
|
2019
|
-
console.warn(
|
|
2020
|
-
`[orchestrator] ${channel.taskId}/${agentId}: rotated send failed: ${
|
|
2021
|
-
err instanceof Error ? err.message : String(err)
|
|
2022
|
-
}`,
|
|
2023
|
-
);
|
|
2024
|
-
});
|
|
2230
|
+
this.startPrompt(channel, agentId, replacement, prompt);
|
|
2231
|
+
} else {
|
|
2232
|
+
this.startNextPendingPrompt(channel, agentId);
|
|
2025
2233
|
}
|
|
2026
2234
|
} finally {
|
|
2027
2235
|
channel.spawning.delete(agentId);
|
|
2236
|
+
if (!replacement) {
|
|
2237
|
+
this.reconcilePendingSecretary(channel, agentId);
|
|
2238
|
+
}
|
|
2028
2239
|
}
|
|
2029
2240
|
|
|
2030
2241
|
// Browser/config setup may have marked other live generations stale. Run
|
|
@@ -2073,6 +2284,10 @@ export class Orchestrator {
|
|
|
2073
2284
|
channel.sessions.delete(agentId);
|
|
2074
2285
|
channel.browserStaleSessions.delete(agentId);
|
|
2075
2286
|
channel.browserPendingStaleSessions.delete(agentId);
|
|
2287
|
+
// The old runner can no longer complete any of its turns. The prompt
|
|
2288
|
+
// itself remains in lastPrompt for the replacement to replay, but its
|
|
2289
|
+
// counters belong to the retired generation and must start over.
|
|
2290
|
+
this.retireRunnerGeneration(channel, agentId);
|
|
2076
2291
|
if (old) {
|
|
2077
2292
|
try {
|
|
2078
2293
|
await old.close();
|
|
@@ -2099,6 +2314,7 @@ export class Orchestrator {
|
|
|
2099
2314
|
task.ownerUserId,
|
|
2100
2315
|
apiUrl,
|
|
2101
2316
|
cliSecret,
|
|
2317
|
+
this.isPromptLevelSecretary(channel, agent.id),
|
|
2102
2318
|
);
|
|
2103
2319
|
channel.accountByAgent.set(agentId, account.id);
|
|
2104
2320
|
noteEngineAccountUsed(account.id);
|
|
@@ -2108,6 +2324,11 @@ export class Orchestrator {
|
|
|
2108
2324
|
agent,
|
|
2109
2325
|
containerName: channel.containerName,
|
|
2110
2326
|
systemPreamble: channel.preambles.get(agentId) ?? "",
|
|
2327
|
+
executionProfile: this.executionProfileFor(channel, agentId),
|
|
2328
|
+
attachCompatibilityKey: this.attachCompatibilityKeyFor(
|
|
2329
|
+
channel,
|
|
2330
|
+
agentId,
|
|
2331
|
+
),
|
|
2111
2332
|
agentEnv: { ...base, ...account.execEnv },
|
|
2112
2333
|
});
|
|
2113
2334
|
if (!replacement) return true;
|
|
@@ -2117,20 +2338,15 @@ export class Orchestrator {
|
|
|
2117
2338
|
// Re-deliver the in-flight prompt so the interrupted turn resumes.
|
|
2118
2339
|
const prompt = channel.lastPrompt.get(agentId);
|
|
2119
2340
|
if (prompt) {
|
|
2120
|
-
this.
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
this.decrementActiveTurns(channel, agentId);
|
|
2124
|
-
}
|
|
2125
|
-
console.warn(
|
|
2126
|
-
`[orchestrator] ${channel.taskId}/${agentId}: respawn send failed: ${
|
|
2127
|
-
err instanceof Error ? err.message : String(err)
|
|
2128
|
-
}`,
|
|
2129
|
-
);
|
|
2130
|
-
});
|
|
2341
|
+
this.startPrompt(channel, agentId, replacement, prompt);
|
|
2342
|
+
} else {
|
|
2343
|
+
this.startNextPendingPrompt(channel, agentId);
|
|
2131
2344
|
}
|
|
2132
2345
|
} finally {
|
|
2133
2346
|
channel.spawning.delete(agentId);
|
|
2347
|
+
if (!replacement) {
|
|
2348
|
+
this.reconcilePendingSecretary(channel, agentId);
|
|
2349
|
+
}
|
|
2134
2350
|
}
|
|
2135
2351
|
|
|
2136
2352
|
if (
|
|
@@ -2252,6 +2468,7 @@ export class Orchestrator {
|
|
|
2252
2468
|
ch.browserStaleSessions.clear();
|
|
2253
2469
|
ch.browserPendingStaleSessions.clear();
|
|
2254
2470
|
ch.activeTurns.clear();
|
|
2471
|
+
ch.pendingPrompts.clear();
|
|
2255
2472
|
ch.openTurns.clear();
|
|
2256
2473
|
ch.interrupted.clear();
|
|
2257
2474
|
ch.reconcileAgain = false;
|
|
@@ -2367,10 +2584,22 @@ export class Orchestrator {
|
|
|
2367
2584
|
|
|
2368
2585
|
/** Exact host-wire role validation; never trims or canonicalises agent ids. */
|
|
2369
2586
|
export function hasValidSecretarySelection(
|
|
2370
|
-
spec: Pick<
|
|
2587
|
+
spec: Pick<
|
|
2588
|
+
ChannelEnsureInput,
|
|
2589
|
+
"mode" | "secretaryAgentId" | "secretaryDispatchProtocol" | "agents"
|
|
2590
|
+
>,
|
|
2371
2591
|
): boolean {
|
|
2372
2592
|
if (spec.mode !== "secretary") return true;
|
|
2593
|
+
if (
|
|
2594
|
+
spec.secretaryDispatchProtocol !==
|
|
2595
|
+
SECRETARY_TYPED_DISPATCH_PROTOCOL_FEATURE
|
|
2596
|
+
) {
|
|
2597
|
+
return false;
|
|
2598
|
+
}
|
|
2373
2599
|
if (!spec.secretaryAgentId) return false;
|
|
2600
|
+
if (spec.agents.some((agent) => agent.id.length > MAX_AGENT_ID_CHARS)) {
|
|
2601
|
+
return false;
|
|
2602
|
+
}
|
|
2374
2603
|
return (
|
|
2375
2604
|
spec.agents.filter((agent) => agent.id === spec.secretaryAgentId).length ===
|
|
2376
2605
|
1
|
|
@@ -2628,6 +2857,14 @@ export function buildSystemPreamble(
|
|
|
2628
2857
|
);
|
|
2629
2858
|
const isSecretary =
|
|
2630
2859
|
mode === "secretary" && secretaryAgentId === agent.id;
|
|
2860
|
+
const dispatchActionBrief = [
|
|
2861
|
+
"When crew work is needed, run the Secretary CLI action:",
|
|
2862
|
+
`\`node ${CONTAINER_CLI_PATH} dispatch @agent [@agent…] \"instruction\"\``,
|
|
2863
|
+
"using exact crew @ids and one concrete, self-contained instruction.",
|
|
2864
|
+
"The command prints a refusal if Uai did not accept the dispatch. Your",
|
|
2865
|
+
"prose stays frontstage: it is not delivered to the crew or written to their",
|
|
2866
|
+
"normal transcript, and naming an agent there does NOT wake them.",
|
|
2867
|
+
];
|
|
2631
2868
|
const transcriptBrief =
|
|
2632
2869
|
mode !== "secretary"
|
|
2633
2870
|
? [
|
|
@@ -2660,15 +2897,17 @@ export function buildSystemPreamble(
|
|
|
2660
2897
|
"## Secretary role",
|
|
2661
2898
|
"",
|
|
2662
2899
|
"You are the channel's sole human-facing communicator. Answer the human",
|
|
2663
|
-
"directly when the transcripts and
|
|
2664
|
-
|
|
2665
|
-
"
|
|
2900
|
+
"directly when the transcripts and your own inspection are sufficient.",
|
|
2901
|
+
...dispatchActionBrief,
|
|
2902
|
+
"Synthesize the",
|
|
2666
2903
|
"crew's replies for the human instead of forwarding a pile of raw updates.",
|
|
2667
2904
|
"",
|
|
2668
|
-
"Your
|
|
2669
|
-
"
|
|
2670
|
-
"
|
|
2671
|
-
"
|
|
2905
|
+
"Your Secretary role is prompt-guided, not tool-restricted. You have the",
|
|
2906
|
+
"same normal tools, approved MCP connections, model choice, and persona",
|
|
2907
|
+
"permissions as any other task agent. Your default job is to communicate,",
|
|
2908
|
+
"coordinate, and dispatch implementation work rather than doing it yourself.",
|
|
2909
|
+
"If the human explicitly asks YOU to make a change, you may use those tools",
|
|
2910
|
+
"and do it directly; otherwise hand mutations to a crew agent.",
|
|
2672
2911
|
"",
|
|
2673
2912
|
]
|
|
2674
2913
|
: [];
|
|
@@ -2676,7 +2915,9 @@ export function buildSystemPreamble(
|
|
|
2676
2915
|
? [
|
|
2677
2916
|
"When a human message names one or more crew agents, those names are",
|
|
2678
2917
|
"routing hints for you — the crew has NOT been notified yet. Decide what",
|
|
2679
|
-
|
|
2918
|
+
`work is actually needed, then run \`node ${CONTAINER_CLI_PATH} dispatch …\``,
|
|
2919
|
+
"with a concrete",
|
|
2920
|
+
"instruction for each",
|
|
2680
2921
|
"crew member you need. Do not merely say that someone else will answer.",
|
|
2681
2922
|
]
|
|
2682
2923
|
: [
|
|
@@ -2723,14 +2964,15 @@ export function buildSystemPreamble(
|
|
|
2723
2964
|
? [
|
|
2724
2965
|
"## Workspace layout",
|
|
2725
2966
|
"",
|
|
2726
|
-
`Your
|
|
2967
|
+
`Your shell starts in \`${workspacePath}\`. It contains one`,
|
|
2727
2968
|
"project worktree per repository:",
|
|
2728
2969
|
"",
|
|
2729
2970
|
...projectLines,
|
|
2730
2971
|
"",
|
|
2731
2972
|
`Those worktrees are on \`${taskBranch}\`. Inspect files when that helps`,
|
|
2732
|
-
"you answer or scope a dispatch.
|
|
2733
|
-
"
|
|
2973
|
+
"you answer or scope a dispatch.",
|
|
2974
|
+
"By default, send edits, commits, pushes, and PR work to a crew agent.",
|
|
2975
|
+
"You may do that work yourself when the human explicitly asks you to.",
|
|
2734
2976
|
"",
|
|
2735
2977
|
"The `.uai/` directory is Uai scaffolding. Read the two transcript files",
|
|
2736
2978
|
"there as described above; do not treat the rest as project content.",
|
|
@@ -2759,13 +3001,65 @@ export function buildSystemPreamble(
|
|
|
2759
3001
|
"treat it as ignored, even though git may show it as untracked.",
|
|
2760
3002
|
"",
|
|
2761
3003
|
];
|
|
3004
|
+
const channelRoutingBrief = isSecretary
|
|
3005
|
+
? [
|
|
3006
|
+
"You are the designated Secretary in a uai task chat channel shared with",
|
|
3007
|
+
"the human and the crew. Your normal prose is frontstage: it is not",
|
|
3008
|
+
"delivered to the crew or shown in their normal feed/transcript. Naming",
|
|
3009
|
+
"or @-mentioning a crew agent in prose does NOT wake them.",
|
|
3010
|
+
"The only way to hand crew work off is the structured `dispatch` action.",
|
|
3011
|
+
]
|
|
3012
|
+
: [
|
|
3013
|
+
"You are one agent in a uai task chat channel, shared with the human",
|
|
3014
|
+
"and the other agents. To hand work to or ask another agent, mention",
|
|
3015
|
+
"it by id at the start of a line — e.g. `@codex please review the",
|
|
3016
|
+
"diff`. uai routes that message into that agent's input.",
|
|
3017
|
+
];
|
|
3018
|
+
const collaborationBrief = isSecretary
|
|
3019
|
+
? [
|
|
3020
|
+
"Collaborate through deliberate dispatches. Each dispatch wakes a crew",
|
|
3021
|
+
"agent and costs a turn, so make every instruction concrete, self-contained,",
|
|
3022
|
+
"and necessary. Never dispatch greetings, thanks, or acknowledgments.",
|
|
3023
|
+
]
|
|
3024
|
+
: [
|
|
3025
|
+
"An agent only receives a message when it is explicitly @-mentioned",
|
|
3026
|
+
"(or addressed by the human) — so always @-mention the agent (or @you)",
|
|
3027
|
+
"you mean. There is NO `peer` command and no shared tmux session;",
|
|
3028
|
+
"hand-offs are just @-mentions in your replies.",
|
|
3029
|
+
"",
|
|
3030
|
+
"Collaborate with your peers — divide up the work, review each other's",
|
|
3031
|
+
"changes, share concrete ideas, and debate approach decisions by",
|
|
3032
|
+
"@-mentioning them. That is how the team gets things done, and you",
|
|
3033
|
+
"should do it freely whenever it moves the work forward. But mentioning",
|
|
3034
|
+
"a peer WAKES it and costs a turn, so make each one count: a message to",
|
|
3035
|
+
"a peer should ADVANCE the work — a real proposal, a question you need",
|
|
3036
|
+
"answered, a hand-off, or a review with specific findings. Do NOT",
|
|
3037
|
+
"@-mention a peer just to greet, thank, agree, acknowledge, or say",
|
|
3038
|
+
"you're ready — content-free replies wake them for nothing and spiral",
|
|
3039
|
+
"into endless back-and-forth. If you have nothing substantive to add,",
|
|
3040
|
+
"don't @-mention back. And when there's no active task yet (intros, or",
|
|
3041
|
+
"you're waiting on the human), answer briefly and then wait — you don't",
|
|
3042
|
+
"need to @-mention anyone (including @you); they can see the channel.",
|
|
3043
|
+
];
|
|
3044
|
+
const channelConventionsBrief = isSecretary
|
|
3045
|
+
? [
|
|
3046
|
+
"Your prose always goes to the human-facing lane; answer plainly without",
|
|
3047
|
+
"re-mentioning the asker. Crew @mentions in prose are labels only. Use",
|
|
3048
|
+
"the structured dispatch action when a crew agent must actually act.",
|
|
3049
|
+
"You may occasionally receive a `[channel check-in]` asking you to catch",
|
|
3050
|
+
"up on the channel.",
|
|
3051
|
+
]
|
|
3052
|
+
: [
|
|
3053
|
+
"Two channel conventions (ADR-050): (1) If your reply @-mentions nobody,",
|
|
3054
|
+
"uai hands it back to whoever prompted you — so when you're ANSWERING,",
|
|
3055
|
+
"just answer plainly; you don't need to re-mention the asker. Mention",
|
|
3056
|
+
"someone only to bring them in or hand work off. (2) You may occasionally",
|
|
3057
|
+
"receive a `[channel check-in]` asking you to catch up on the channel.",
|
|
3058
|
+
];
|
|
2762
3059
|
const comms = [
|
|
2763
3060
|
"## uai task channel",
|
|
2764
3061
|
"",
|
|
2765
|
-
|
|
2766
|
-
"and the other agents. To hand work to or ask another agent, mention",
|
|
2767
|
-
"it by id at the start of a line — e.g. `@codex please review the",
|
|
2768
|
-
"diff`. uai routes that message into that agent's input.",
|
|
3062
|
+
...channelRoutingBrief,
|
|
2769
3063
|
"",
|
|
2770
3064
|
`**You are @${agent.id}** — your name in this channel is **${agent.label}**.`,
|
|
2771
3065
|
`Introduce and refer to yourself as ${agent.label}, not as a generic`,
|
|
@@ -2775,35 +3069,14 @@ export function buildSystemPreamble(
|
|
|
2775
3069
|
"",
|
|
2776
3070
|
...humanIntro,
|
|
2777
3071
|
"",
|
|
2778
|
-
|
|
2779
|
-
"(or addressed by the human) — so always @-mention the agent (or @you)",
|
|
2780
|
-
"you mean. There is NO `peer` command and no shared tmux session;",
|
|
2781
|
-
"hand-offs are just @-mentions in your replies.",
|
|
2782
|
-
"",
|
|
2783
|
-
"Collaborate with your peers — divide up the work, review each other's",
|
|
2784
|
-
"changes, share concrete ideas, and debate approach decisions by",
|
|
2785
|
-
"@-mentioning them. That is how the team gets things done, and you",
|
|
2786
|
-
"should do it freely whenever it moves the work forward. But mentioning",
|
|
2787
|
-
"a peer WAKES it and costs a turn, so make each one count: a message to",
|
|
2788
|
-
"a peer should ADVANCE the work — a real proposal, a question you need",
|
|
2789
|
-
"answered, a hand-off, or a review with specific findings. Do NOT",
|
|
2790
|
-
"@-mention a peer just to greet, thank, agree, acknowledge, or say",
|
|
2791
|
-
"you're ready — content-free replies wake them for nothing and spiral",
|
|
2792
|
-
"into endless back-and-forth. If you have nothing substantive to add,",
|
|
2793
|
-
"don't @-mention back. And when there's no active task yet (intros, or",
|
|
2794
|
-
"you're waiting on the human), answer briefly and then wait — you don't",
|
|
2795
|
-
"need to @-mention anyone (including @you); they can see the channel.",
|
|
3072
|
+
...collaborationBrief,
|
|
2796
3073
|
"",
|
|
2797
3074
|
...groupMessageBrief,
|
|
2798
3075
|
"",
|
|
2799
3076
|
...transcriptBrief,
|
|
2800
3077
|
"",
|
|
2801
3078
|
...secretaryRoleBrief,
|
|
2802
|
-
|
|
2803
|
-
"uai hands it back to whoever prompted you — so when you're ANSWERING,",
|
|
2804
|
-
"just answer plainly; you don't need to re-mention the asker. Mention",
|
|
2805
|
-
"someone only to bring them in or hand work off. (2) You may occasionally",
|
|
2806
|
-
"receive a `[channel check-in]` asking you to catch up on the channel.",
|
|
3079
|
+
...channelConventionsBrief,
|
|
2807
3080
|
...checkInTranscriptBrief,
|
|
2808
3081
|
"",
|
|
2809
3082
|
...handoffBrief,
|
|
@@ -2817,12 +3090,12 @@ export function buildSystemPreamble(
|
|
|
2817
3090
|
? [
|
|
2818
3091
|
"## Shared files",
|
|
2819
3092
|
"",
|
|
2820
|
-
`Non-code files (${sharedFiles === "rw"
|
|
3093
|
+
`Non-code files (${sharedFiles === "rw" ? "read-write" : "READ-ONLY"} for this task):`,
|
|
2821
3094
|
"- `/workspace/files/org` — the org's shared files (logos, specs,",
|
|
2822
3095
|
" datasets), visible to every task in the org on this host.",
|
|
2823
3096
|
"- `/workspace/files/me` — the task owner's personal files, shared",
|
|
2824
3097
|
" across their tasks on this host.",
|
|
2825
|
-
...(sharedFiles === "rw"
|
|
3098
|
+
...(sharedFiles === "rw"
|
|
2826
3099
|
? [
|
|
2827
3100
|
"When producing artifacts for humans, write them here (use a",
|
|
2828
3101
|
"subdirectory named after the task to avoid collisions).",
|
|
@@ -2849,8 +3122,7 @@ export function buildSystemPreamble(
|
|
|
2849
3122
|
// ADR-047: package skills are native Claude Agent Skills installed into the
|
|
2850
3123
|
// container's skills dir (Claude-only). List them so the agent knows they're
|
|
2851
3124
|
// available even if headless auto-discovery is unreliable.
|
|
2852
|
-
...(
|
|
2853
|
-
agent.kind === "claude" &&
|
|
3125
|
+
...(agent.kind === "claude" &&
|
|
2854
3126
|
(agent.skills ?? []).some((s) => s.type === "package")
|
|
2855
3127
|
? [
|
|
2856
3128
|
"## Installed skills",
|
|
@@ -2864,7 +3136,7 @@ export function buildSystemPreamble(
|
|
|
2864
3136
|
]
|
|
2865
3137
|
: []),
|
|
2866
3138
|
// ADR-053: the in-container browser, when the project opted in.
|
|
2867
|
-
...(browserTesting
|
|
3139
|
+
...(browserTesting
|
|
2868
3140
|
? [
|
|
2869
3141
|
"## Browser",
|
|
2870
3142
|
"",
|
|
@@ -2886,12 +3158,10 @@ export function buildSystemPreamble(
|
|
|
2886
3158
|
"",
|
|
2887
3159
|
]
|
|
2888
3160
|
: []),
|
|
2889
|
-
// ADR-048: tell
|
|
2890
|
-
//
|
|
2891
|
-
//
|
|
2892
|
-
|
|
2893
|
-
// surface the safe task/todo subset without widening this boundary.
|
|
2894
|
-
...((agent.permissions?.length ?? 0) > 0 && !isSecretary
|
|
3161
|
+
// ADR-048: tell every agent with persona-granted permissions about its
|
|
3162
|
+
// `uai` CLI. Secretary mode adds dispatch but does not invent any other
|
|
3163
|
+
// permission: an empty persona permission set remains empty.
|
|
3164
|
+
...((agent.permissions?.length ?? 0) > 0
|
|
2895
3165
|
? [
|
|
2896
3166
|
"## The uai CLI",
|
|
2897
3167
|
"",
|
|
@@ -2981,19 +3251,15 @@ export function buildSystemPreamble(
|
|
|
2981
3251
|
"",
|
|
2982
3252
|
]
|
|
2983
3253
|
: []),
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
|
|
2989
|
-
|
|
2990
|
-
|
|
2991
|
-
|
|
2992
|
-
|
|
2993
|
-
"Write commit messages and PR descriptions plainly, as the author, with",
|
|
2994
|
-
"no agent attribution.",
|
|
2995
|
-
]
|
|
2996
|
-
: []),
|
|
3254
|
+
"## Commit policy",
|
|
3255
|
+
"",
|
|
3256
|
+
"When this task has an optional SSH signing key configured, Git signs",
|
|
3257
|
+
"commits automatically; do not disable or override it. Do NOT add any",
|
|
3258
|
+
"`Co-Authored-By:`",
|
|
3259
|
+
"trailers to commit messages, and do NOT add 'Generated with …' or any",
|
|
3260
|
+
"tool/agent attribution footer to commit messages or PR/issue bodies.",
|
|
3261
|
+
"Write commit messages and PR descriptions plainly, as the author, with",
|
|
3262
|
+
"no agent attribution.",
|
|
2997
3263
|
].join("\n");
|
|
2998
3264
|
|
|
2999
3265
|
// Persona / mission layers, always-on so they apply to every turn:
|
|
@@ -3172,6 +3438,38 @@ async function dockerStart(containerName: string): Promise<boolean> {
|
|
|
3172
3438
|
return true;
|
|
3173
3439
|
}
|
|
3174
3440
|
|
|
3441
|
+
/**
|
|
3442
|
+
* Repair the shared node data root before any recovered-container init.
|
|
3443
|
+
* Older task-up versions created the OpenCode leaf as root and accidentally
|
|
3444
|
+
* left this parent root-owned, which prevented code-server from creating its
|
|
3445
|
+
* managed User profile. `install -d` creates or repairs the exact directory
|
|
3446
|
+
* without recursively changing unrelated application state beneath it.
|
|
3447
|
+
*/
|
|
3448
|
+
async function repairNodeDataRoot(containerName: string): Promise<boolean> {
|
|
3449
|
+
const res = await dockerCli([
|
|
3450
|
+
"exec",
|
|
3451
|
+
"-u",
|
|
3452
|
+
"root",
|
|
3453
|
+
containerName,
|
|
3454
|
+
"/usr/bin/install",
|
|
3455
|
+
"-d",
|
|
3456
|
+
"-o",
|
|
3457
|
+
"node",
|
|
3458
|
+
"-g",
|
|
3459
|
+
"node",
|
|
3460
|
+
"-m",
|
|
3461
|
+
"0755",
|
|
3462
|
+
"/home/node/.local/share",
|
|
3463
|
+
]);
|
|
3464
|
+
if (res.status !== 0) {
|
|
3465
|
+
console.error(
|
|
3466
|
+
`[orchestrator] recovery: ${containerName} could not repair /home/node/.local/share ownership: ${res.stderr.trim() || `docker exec exited ${String(res.status)}`}`,
|
|
3467
|
+
);
|
|
3468
|
+
return false;
|
|
3469
|
+
}
|
|
3470
|
+
return true;
|
|
3471
|
+
}
|
|
3472
|
+
|
|
3175
3473
|
async function dockerPort(
|
|
3176
3474
|
containerName: string,
|
|
3177
3475
|
containerPort: number,
|
|
@@ -3372,6 +3670,9 @@ async function recoverOneTask(
|
|
|
3372
3670
|
});
|
|
3373
3671
|
return true;
|
|
3374
3672
|
}
|
|
3673
|
+
// uai-init seeds code-server's profile beneath this shared data root. Heal
|
|
3674
|
+
// containers created by older hosts before running it as node.
|
|
3675
|
+
await repairNodeDataRoot(containerName);
|
|
3375
3676
|
// Correctly-owned Codex creds BEFORE uai-init / any agent respawn: the boot
|
|
3376
3677
|
// reinject sweep only targets containers already running, so a container
|
|
3377
3678
|
// recovered here would otherwise keep whatever it held when it exited —
|