omp-conductor 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +214 -66
- package/package.json +1 -1
- package/src/availability.ts +165 -0
- package/src/briefs/orchestrator.md +63 -26
- package/src/briefs/policy.md +44 -32
- package/src/cli.ts +122 -14
- package/src/config.ts +113 -5
- package/src/daemon.ts +218 -32
- package/src/diff-flags.ts +73 -4
- package/src/digest-schedule.ts +92 -24
- package/src/escalate.ts +46 -19
- package/src/fleet.ts +34 -3
- package/src/orchestrator-tick.ts +437 -20
- package/src/plugin.ts +138 -12
- package/src/reports.ts +202 -5
- package/src/setup.ts +193 -33
- package/src/store.ts +610 -98
- package/src/tracker/github.ts +43 -5
- package/src/types.ts +151 -12
- package/src/verbs/actions.ts +131 -13
- package/src/verbs/server.ts +8 -9
- package/src/worker.ts +18 -6
package/src/orchestrator-tick.ts
CHANGED
|
@@ -44,9 +44,11 @@
|
|
|
44
44
|
* config, believed it held both.
|
|
45
45
|
*/
|
|
46
46
|
|
|
47
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
47
48
|
import { spawnSync } from "node:child_process";
|
|
48
49
|
import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
49
50
|
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
51
|
+
import { availabilityPrompt, interruptDisposition } from "./availability.ts";
|
|
50
52
|
import { findProject, loadConfig, resolveReleaseGrants } from "./config.ts";
|
|
51
53
|
import {
|
|
52
54
|
bridgeTokenBound,
|
|
@@ -58,6 +60,7 @@ import {
|
|
|
58
60
|
import {
|
|
59
61
|
briefPathForProject,
|
|
60
62
|
policyPathForProject,
|
|
63
|
+
reportScopeFromPolicy,
|
|
61
64
|
refreshComposedBriefForProject,
|
|
62
65
|
} from "./setup.ts";
|
|
63
66
|
import {
|
|
@@ -73,8 +76,11 @@ import {
|
|
|
73
76
|
DEFAULT_REPORT_SCOPE,
|
|
74
77
|
DENIED_RELEASE_GRANTS,
|
|
75
78
|
type DispatchSummary,
|
|
79
|
+
type DigestBacklog,
|
|
76
80
|
type FrictionSignal,
|
|
77
|
-
type
|
|
81
|
+
type HeldNotice,
|
|
82
|
+
type MaterialEvent,
|
|
83
|
+
type ReportScopeChoice,
|
|
78
84
|
type ReportingPolicy,
|
|
79
85
|
type ResolvedGrants,
|
|
80
86
|
type Store,
|
|
@@ -82,7 +88,7 @@ import {
|
|
|
82
88
|
import { formatDecisionDigest } from "./decisions.ts";
|
|
83
89
|
import type { RunRecord } from "./types.ts";
|
|
84
90
|
import { dbPath, openStore } from "./store.ts";
|
|
85
|
-
import { digestDue } from "./digest-schedule.ts";
|
|
91
|
+
import { digestDue, localDayKey } from "./digest-schedule.ts";
|
|
86
92
|
|
|
87
93
|
/** The activation file. Absent means "this is not an orchestrator session". */
|
|
88
94
|
export const TICK_CONFIG_FILE = ".conductor-tick.json";
|
|
@@ -111,6 +117,8 @@ const FRICTION_DIGEST_LIMIT = 3;
|
|
|
111
117
|
* definitive, either way.
|
|
112
118
|
*/
|
|
113
119
|
const RETRY_OWNERSHIP_MS = 60_000;
|
|
120
|
+
/** A failed digest turn must release working-hours catch-up ownership promptly. */
|
|
121
|
+
const MIN_AVAILABILITY_DIGEST_LEASE_MS = 30 * 60 * 1_000;
|
|
114
122
|
|
|
115
123
|
/**
|
|
116
124
|
* The stall marker — written beside the activation file, in the session cwd —
|
|
@@ -258,6 +266,23 @@ interface TickApi {
|
|
|
258
266
|
on(event: "session_start", handler: (event: { type: "session_start" }, ctx: TickContext) => void): void;
|
|
259
267
|
on(event: "turn_start", handler: (event: { type: "turn_start" }, ctx: TickContext) => void): void;
|
|
260
268
|
on(event: "turn_end", handler: (event: { type: "turn_end" }, ctx: TickContext) => void): void;
|
|
269
|
+
on(
|
|
270
|
+
event: "message_start",
|
|
271
|
+
handler: (
|
|
272
|
+
event: {
|
|
273
|
+
type: "message_start";
|
|
274
|
+
message: {
|
|
275
|
+
role: string;
|
|
276
|
+
customType?: string;
|
|
277
|
+
synthetic?: boolean;
|
|
278
|
+
attribution?: "user" | "agent";
|
|
279
|
+
timestamp?: number;
|
|
280
|
+
};
|
|
281
|
+
},
|
|
282
|
+
ctx: TickContext,
|
|
283
|
+
) => void,
|
|
284
|
+
): void;
|
|
285
|
+
on(event: "agent_end", handler: (event: { type: "agent_end" }, ctx: TickContext) => void): void;
|
|
261
286
|
/**
|
|
262
287
|
* `deliverAs: "followUp"` + `triggerTurn: true`, verified against
|
|
263
288
|
* `AgentSession.sendCustomMessage` rather than assumed:
|
|
@@ -410,14 +435,58 @@ export function queueDigestLine(
|
|
|
410
435
|
return line;
|
|
411
436
|
}
|
|
412
437
|
|
|
413
|
-
export const TICK_SCOPE_CONSTRAINTS: { readonly [K in
|
|
438
|
+
export const TICK_SCOPE_CONSTRAINTS: { readonly [K in ReportScopeChoice]: string } = {
|
|
414
439
|
material: "Report material events per your brief.",
|
|
415
440
|
escalations:
|
|
416
441
|
"Interrupt only for: tier2, fleet-stopped; everything else — releases included — waits for the daily digest.",
|
|
417
442
|
decisions:
|
|
418
|
-
"Reporting scope decisions: interrupt only for a decision you need (tier-2) or a condition that stops the fleet. Every other material event
|
|
443
|
+
"Reporting scope decisions: interrupt only for a decision you need (tier-2) or a condition that stops the fleet. Every other material event waits for the configured digest; nothing between digests.",
|
|
444
|
+
quiet:
|
|
445
|
+
"Interrupt only for: tier2, fleet-stopped, confirmed-failure; everything else waits for the daily digest.",
|
|
419
446
|
};
|
|
420
447
|
|
|
448
|
+
/** Bounded, durable source material for one due digest. Row ids are part of the
|
|
449
|
+
* handoff contract: the report and exactly the rows it consumed settle in one
|
|
450
|
+
* SQLite transaction, so a crash cannot lose an outcome between those writes. */
|
|
451
|
+
export function formatDigestLedger(
|
|
452
|
+
events: readonly MaterialEvent[],
|
|
453
|
+
notices: readonly HeldNotice[],
|
|
454
|
+
backlog: DigestBacklog,
|
|
455
|
+
): string {
|
|
456
|
+
const oneLine = (text: string, limit: number): string => {
|
|
457
|
+
const flat = text.replace(/\s+/g, " ").trim();
|
|
458
|
+
return flat.length <= limit ? flat : `${flat.slice(0, limit - 1)}…`;
|
|
459
|
+
};
|
|
460
|
+
const lines = [
|
|
461
|
+
`Durable digest ledger — ${backlog.materialCount} material event(s), ${backlog.heldNoticeCount} held escalation(s); oldest first:`,
|
|
462
|
+
...events.map(
|
|
463
|
+
(event) =>
|
|
464
|
+
` event ${event.id} | ${new Date(event.occurredAt).toISOString()} | ${event.category} | ` +
|
|
465
|
+
`${oneLine(event.summary, 180)} | evidence: ${oneLine(event.evidence, 240)}`,
|
|
466
|
+
),
|
|
467
|
+
...notices.map(
|
|
468
|
+
(notice) =>
|
|
469
|
+
` notice ${notice.id} | ${new Date(notice.createdAt).toISOString()} | ${notice.category} | ` +
|
|
470
|
+
`${oneLine(notice.summary, 180)} | detail: ${oneLine(notice.detail, 240)}`,
|
|
471
|
+
),
|
|
472
|
+
];
|
|
473
|
+
if (backlog.materialCount > events.length) {
|
|
474
|
+
lines.push(` … ${backlog.materialCount - events.length} newer material event(s) remain owed.`);
|
|
475
|
+
}
|
|
476
|
+
if (backlog.heldNoticeCount > notices.length) {
|
|
477
|
+
lines.push(` … ${backlog.heldNoticeCount - notices.length} additional held escalation(s) remain owed.`);
|
|
478
|
+
}
|
|
479
|
+
const eventIds = events.map((event) => event.id).join(",");
|
|
480
|
+
const noticeIds = notices.map((notice) => notice.id).join(",");
|
|
481
|
+
lines.push(
|
|
482
|
+
"Compose the digest from the relevant rows above. Hand it off with omp-conductor report --kind digest " +
|
|
483
|
+
`${eventIds.length === 0 ? "" : `--events ${eventIds} `}` +
|
|
484
|
+
`${noticeIds.length === 0 ? "" : `--notices ${noticeIds} `}` +
|
|
485
|
+
"--text TEXT. Remove any row id you did not actually include; omitted rows remain owed. Never invent an id.",
|
|
486
|
+
);
|
|
487
|
+
return lines.join("\n");
|
|
488
|
+
}
|
|
489
|
+
|
|
421
490
|
/**
|
|
422
491
|
* The reporting constraint appended to a default tick prompt (#229, #242).
|
|
423
492
|
*
|
|
@@ -449,9 +518,13 @@ export function tickReportingConstraint(
|
|
|
449
518
|
? "Report nothing that would interrupt this turn; everything else accumulates for the digest."
|
|
450
519
|
: `Interrupt only for: ${allowed.join(", ")}. Everything else accumulates for the digest.`;
|
|
451
520
|
}
|
|
521
|
+
if (policy !== undefined && !allowed.includes("material")) {
|
|
522
|
+
base +=
|
|
523
|
+
" Record every material outcome that is not sent as an interrupt immediately with omp-conductor event record; the durable ledger, not session memory, feeds the next digest.";
|
|
524
|
+
}
|
|
452
525
|
if (digest.cadence !== "daily") return base;
|
|
453
526
|
if (digest.due) {
|
|
454
|
-
return `${base} The daily digest is DUE now — compose it from
|
|
527
|
+
return `${base} The daily digest is DUE now — compose it from the durable accumulated events and ${held} held notice(s), then send via omp-conductor report --kind digest.`;
|
|
455
528
|
}
|
|
456
529
|
return digest.at === undefined
|
|
457
530
|
? `${base} The daily digest was already sent today; do not send another.`
|
|
@@ -489,13 +562,14 @@ export function tickReportingConstraint(
|
|
|
489
562
|
* report is indistinguishable from a quiet tick. #123 moved delivery to the
|
|
490
563
|
* daemon, so the clause now names the handover instead. `omp-conductor report`
|
|
491
564
|
* persists the text before anything is sent and the daemon retries it until it
|
|
492
|
-
* lands; a handover that returns a report id has
|
|
493
|
-
* the failure mode this rule exists for, which a
|
|
494
|
-
* could. `telegram_send` remains the right call
|
|
495
|
-
* person who is waiting — that is a
|
|
565
|
+
* lands; a handover that returns a durable report or held-notice id has
|
|
566
|
+
* therefore *already* survived the failure mode this rule exists for, which a
|
|
567
|
+
* watched `telegram_send` never could. `telegram_send` remains the right call
|
|
568
|
+
* for an interactive reply to a person who is waiting — that is a
|
|
569
|
+
* conversation, not a report.
|
|
496
570
|
*/
|
|
497
571
|
export const TICK_DELIVERY_RULE =
|
|
498
|
-
"This tick was injected locally, not sent from Telegram, so your end-of-turn text does NOT reach your operator. Hand anything reportable this turn to the durable outbox by running `omp-conductor report --text \"<the whole report>\"` (add `--kind digest` for the daily digest) and confirming it printed a report id; the daemon then owns delivery and retries until it lands. Never claim a report was sent otherwise, and never use telegram_send for a report -- that path leaves no record that it went out.";
|
|
572
|
+
"This tick was injected locally, not sent from Telegram, so your end-of-turn text does NOT reach your operator. Hand anything reportable this turn to the durable outbox by running `omp-conductor report --text \"<the whole report>\"` (add `--kind digest` for the daily digest) and confirming it printed a durable handoff id (a report id, or a held-notice id during quiet hours); the daemon then owns delivery and retries until it lands. Never claim a report was sent otherwise, and never use telegram_send for a report -- that path leaves no record that it went out.";
|
|
499
573
|
|
|
500
574
|
/** The {@link TICK_DELIVERY_RULE} variant for a fleet whose bridge actually
|
|
501
575
|
* delivers the tick's ending text (#169). That is a narrower class than the
|
|
@@ -538,8 +612,9 @@ export { TELEGRAM_APPROVAL_TOOL };
|
|
|
538
612
|
*/
|
|
539
613
|
export const TICK_APPROVAL_UNAVAILABLE_RULE =
|
|
540
614
|
`The ${TELEGRAM_APPROVAL_TOOL} tool is NOT mounted on this tick, so the package floor's yes/no amendment approval cannot be asked here. ` +
|
|
541
|
-
`If you have an amendment to propose, deliver the question with telegram_send and wait for your operator's reply on a later turn
|
|
542
|
-
`
|
|
615
|
+
`If you have an amendment to propose, deliver the question with telegram_send, prefix its text with "QUESTION:", and wait for your operator's reply on a later turn. ` +
|
|
616
|
+
`A returned telegram_ask answer proves an answer, not Telegram delivery. ` +
|
|
617
|
+
`Never apply an amendment, or record one as approved, without an explicit answer you actually received.`;
|
|
543
618
|
|
|
544
619
|
/**
|
|
545
620
|
* Appended to every tick — the shipped prompt or the operator's own — composed
|
|
@@ -625,7 +700,7 @@ export function formatFrictionDigest(signals: readonly FrictionSignal[]): string
|
|
|
625
700
|
* trade.
|
|
626
701
|
*/
|
|
627
702
|
export function resolveTickScope(): {
|
|
628
|
-
scope:
|
|
703
|
+
scope: ReportScopeChoice;
|
|
629
704
|
policy?: ReportingPolicy;
|
|
630
705
|
briefPath?: string;
|
|
631
706
|
policyPath?: string;
|
|
@@ -635,7 +710,7 @@ export function resolveTickScope(): {
|
|
|
635
710
|
try {
|
|
636
711
|
const project = findProject(loadConfig());
|
|
637
712
|
return {
|
|
638
|
-
scope: project.reporting
|
|
713
|
+
scope: reportScopeFromPolicy(project.reporting),
|
|
639
714
|
policy: project.reporting,
|
|
640
715
|
briefPath: briefPathForProject(project),
|
|
641
716
|
policyPath: policyPathForProject(project),
|
|
@@ -1282,6 +1357,228 @@ function clearTickRequest(pi: TickApi, cwd: string): void {
|
|
|
1282
1357
|
}
|
|
1283
1358
|
}
|
|
1284
1359
|
|
|
1360
|
+
interface PendingLocalTick {
|
|
1361
|
+
id: string;
|
|
1362
|
+
projectName?: string;
|
|
1363
|
+
policy?: ReportingPolicy;
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
interface ActiveLocalTick extends PendingLocalTick {
|
|
1367
|
+
/** A person who writes during an autonomous run is awake by construction. */
|
|
1368
|
+
humanWaiting: boolean;
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
interface TelegramInterrupt {
|
|
1372
|
+
kind: "question" | "message";
|
|
1373
|
+
category: HeldNotice["category"];
|
|
1374
|
+
summary: string;
|
|
1375
|
+
detail: string;
|
|
1376
|
+
hasFiles: boolean;
|
|
1377
|
+
fingerprint: string;
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
function objectRecord(value: unknown): value is Record<string, unknown> {
|
|
1381
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1382
|
+
}
|
|
1383
|
+
function canonicalTelegramPayload(value: unknown): unknown {
|
|
1384
|
+
if (Array.isArray(value)) return value.map(canonicalTelegramPayload);
|
|
1385
|
+
if (!objectRecord(value)) return value;
|
|
1386
|
+
return Object.fromEntries(
|
|
1387
|
+
Object.keys(value)
|
|
1388
|
+
.sort()
|
|
1389
|
+
.map((key) => [key, canonicalTelegramPayload(value[key])]),
|
|
1390
|
+
);
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
|
|
1394
|
+
function serialiseTelegramPayload(value: unknown): string {
|
|
1395
|
+
try {
|
|
1396
|
+
return JSON.stringify(canonicalTelegramPayload(value)) ?? String(value);
|
|
1397
|
+
} catch {
|
|
1398
|
+
return String(value);
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
function telegramInterruptFromTool(
|
|
1403
|
+
toolName: string,
|
|
1404
|
+
input: Record<string, unknown>,
|
|
1405
|
+
): TelegramInterrupt | undefined {
|
|
1406
|
+
let kind: TelegramInterrupt["kind"];
|
|
1407
|
+
let payload: Record<string, unknown> = input;
|
|
1408
|
+
|
|
1409
|
+
if (toolName === TELEGRAM_APPROVAL_TOOL) {
|
|
1410
|
+
kind = "question";
|
|
1411
|
+
} else if (toolName === "telegram_send" || toolName === "telegram_react") {
|
|
1412
|
+
kind = "message";
|
|
1413
|
+
} else if (toolName === "write") {
|
|
1414
|
+
const path = input["path"];
|
|
1415
|
+
if (
|
|
1416
|
+
path !== "xd://telegram_ask" &&
|
|
1417
|
+
path !== "xd://telegram_send" &&
|
|
1418
|
+
path !== "xd://telegram_react"
|
|
1419
|
+
) {
|
|
1420
|
+
return undefined;
|
|
1421
|
+
}
|
|
1422
|
+
kind = path === "xd://telegram_ask" ? "question" : "message";
|
|
1423
|
+
const content = input["content"];
|
|
1424
|
+
if (typeof content === "string") {
|
|
1425
|
+
try {
|
|
1426
|
+
const parsed: unknown = JSON.parse(content);
|
|
1427
|
+
payload = objectRecord(parsed) ? parsed : { content };
|
|
1428
|
+
} catch {
|
|
1429
|
+
payload = { content };
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
} else {
|
|
1433
|
+
return undefined;
|
|
1434
|
+
}
|
|
1435
|
+
if (
|
|
1436
|
+
kind === "message" &&
|
|
1437
|
+
typeof payload["text"] === "string" &&
|
|
1438
|
+
/^\s*QUESTION:\s/i.test(payload["text"])
|
|
1439
|
+
) {
|
|
1440
|
+
// The package floor uses this explicit fallback when telegram_ask is not
|
|
1441
|
+
// mounted. Without the marker a plain send remains a material update.
|
|
1442
|
+
kind = "question";
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
let detail = "";
|
|
1446
|
+
if (kind === "question" && Array.isArray(payload["questions"])) {
|
|
1447
|
+
detail = payload["questions"]
|
|
1448
|
+
.map((value, index) => {
|
|
1449
|
+
if (!objectRecord(value)) return `Question ${index + 1}: ${serialiseTelegramPayload(value)}`;
|
|
1450
|
+
const question = typeof value["question"] === "string" ? value["question"] : "Question";
|
|
1451
|
+
const options = Array.isArray(value["options"])
|
|
1452
|
+
? value["options"]
|
|
1453
|
+
.map((option) =>
|
|
1454
|
+
objectRecord(option) && typeof option["label"] === "string"
|
|
1455
|
+
? `${option["label"]}${
|
|
1456
|
+
typeof option["description"] === "string" && option["description"] !== ""
|
|
1457
|
+
? ` — ${option["description"]}`
|
|
1458
|
+
: ""
|
|
1459
|
+
}`
|
|
1460
|
+
: serialiseTelegramPayload(option),
|
|
1461
|
+
)
|
|
1462
|
+
.join("; ")
|
|
1463
|
+
: "";
|
|
1464
|
+
return options === "" ? question : `${question}\nOptions: ${options}`;
|
|
1465
|
+
})
|
|
1466
|
+
.join("\n\n");
|
|
1467
|
+
} else if (typeof payload["text"] === "string") {
|
|
1468
|
+
detail = payload["text"];
|
|
1469
|
+
}
|
|
1470
|
+
if (detail.trim() === "") detail = serialiseTelegramPayload(payload);
|
|
1471
|
+
|
|
1472
|
+
const flat = detail.replace(/\s+/g, " ").trim();
|
|
1473
|
+
const fallback = kind === "question" ? "Operator question" : "Telegram message";
|
|
1474
|
+
const summary = flat === "" ? fallback : flat.length <= 180 ? flat : `${flat.slice(0, 179)}…`;
|
|
1475
|
+
const canonical = serialiseTelegramPayload({ kind, payload });
|
|
1476
|
+
return {
|
|
1477
|
+
kind,
|
|
1478
|
+
category: kind === "question" ? "decision-needed" : "material",
|
|
1479
|
+
summary,
|
|
1480
|
+
hasFiles: Array.isArray(payload["files"]) && payload["files"].length > 0,
|
|
1481
|
+
detail,
|
|
1482
|
+
fingerprint: createHash("sha256").update(canonical).digest("hex"),
|
|
1483
|
+
};
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
type TelegramInterruptBlock = { block: true; reason: string };
|
|
1487
|
+
|
|
1488
|
+
/**
|
|
1489
|
+
* A locally injected tick is an autonomous actor. Its direct Telegram calls
|
|
1490
|
+
* therefore pass through the same category and availability decision as daemon
|
|
1491
|
+
* escalations. Human-originated runs, including a message steered into a tick,
|
|
1492
|
+
* are exempt: the operator is already present and waiting for the reply.
|
|
1493
|
+
*/
|
|
1494
|
+
function autonomousTelegramInterruptBlock(
|
|
1495
|
+
pi: TickApi,
|
|
1496
|
+
session: TickSession,
|
|
1497
|
+
event: { toolName: string; input: Record<string, unknown> },
|
|
1498
|
+
): TelegramInterruptBlock | undefined {
|
|
1499
|
+
const active = session.activeLocalTick;
|
|
1500
|
+
if (active === undefined || active.humanWaiting) return undefined;
|
|
1501
|
+
const interrupt = telegramInterruptFromTool(event.toolName, event.input);
|
|
1502
|
+
if (interrupt === undefined) return undefined;
|
|
1503
|
+
if (active.projectName === undefined) {
|
|
1504
|
+
return {
|
|
1505
|
+
block: true,
|
|
1506
|
+
reason:
|
|
1507
|
+
"Operator availability policy is unavailable for this autonomous tick, so direct Telegram is blocked fail-closed. " +
|
|
1508
|
+
"Repair the conductor config; do not retry through another Telegram path.",
|
|
1509
|
+
};
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
let policy = active.policy;
|
|
1513
|
+
let configReadable = true;
|
|
1514
|
+
try {
|
|
1515
|
+
policy = findProject(loadConfig(), active.projectName).reporting;
|
|
1516
|
+
} catch (err) {
|
|
1517
|
+
configReadable = false;
|
|
1518
|
+
pi.logger.error(
|
|
1519
|
+
`[omp-conductor] operator availability config unreadable during Telegram call; blocking fail-closed: ${
|
|
1520
|
+
err instanceof Error ? err.message : String(err)
|
|
1521
|
+
}`,
|
|
1522
|
+
);
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
const at = Date.now();
|
|
1526
|
+
const disposition = configReadable
|
|
1527
|
+
? interruptDisposition(policy, interrupt.category, at)
|
|
1528
|
+
: "availability";
|
|
1529
|
+
if (disposition === "interrupt") return undefined;
|
|
1530
|
+
const release =
|
|
1531
|
+
disposition === "availability"
|
|
1532
|
+
? "the availability window opens or the daily digest runs"
|
|
1533
|
+
: "the daily digest runs";
|
|
1534
|
+
if (interrupt.hasFiles) {
|
|
1535
|
+
return {
|
|
1536
|
+
block: true,
|
|
1537
|
+
reason:
|
|
1538
|
+
`Operator policy forbids this autonomous Telegram ${interrupt.kind} until ${release}, ` +
|
|
1539
|
+
"and attachment-bearing Telegram sends cannot be durably replayed. Nothing was sent or held. " +
|
|
1540
|
+
"Do not retry through another Telegram path while the policy forbids delivery.",
|
|
1541
|
+
};
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
const key = `${active.projectName}:autonomous-telegram:${active.id}:${interrupt.fingerprint}`;
|
|
1545
|
+
try {
|
|
1546
|
+
const store = openStore(dbPath());
|
|
1547
|
+
try {
|
|
1548
|
+
if (!store.wasNotified(key)) {
|
|
1549
|
+
store.addHeldNotice({
|
|
1550
|
+
id: createHash("sha256").update(`held-notice\0${key}`).digest("hex"),
|
|
1551
|
+
project: active.projectName,
|
|
1552
|
+
category: interrupt.category,
|
|
1553
|
+
summary: interrupt.summary,
|
|
1554
|
+
detail: interrupt.detail,
|
|
1555
|
+
createdAt: at,
|
|
1556
|
+
...(disposition === "availability" ? { releaseOnAvailable: true } : {}),
|
|
1557
|
+
});
|
|
1558
|
+
store.markNotified(key);
|
|
1559
|
+
}
|
|
1560
|
+
} finally {
|
|
1561
|
+
store.close();
|
|
1562
|
+
}
|
|
1563
|
+
} catch (err) {
|
|
1564
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
1565
|
+
pi.logger.error(`[omp-conductor] could not persist held Telegram ${interrupt.kind}: ${reason}`);
|
|
1566
|
+
return {
|
|
1567
|
+
block: true,
|
|
1568
|
+
reason:
|
|
1569
|
+
`Operator policy forbids this autonomous Telegram ${interrupt.kind}, and the durable hold failed: ${reason}. ` +
|
|
1570
|
+
"Do not retry through another Telegram path.",
|
|
1571
|
+
};
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
return {
|
|
1575
|
+
block: true,
|
|
1576
|
+
reason:
|
|
1577
|
+
`Operator policy durably held this autonomous Telegram ${interrupt.kind} until ${release}. ` +
|
|
1578
|
+
"Do not retry through another Telegram path.",
|
|
1579
|
+
};
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1285
1582
|
/** Everything one tick remembers for the next. */
|
|
1286
1583
|
interface TickSession {
|
|
1287
1584
|
/**
|
|
@@ -1310,6 +1607,10 @@ interface TickSession {
|
|
|
1310
1607
|
bridgeTokenAtStart: boolean;
|
|
1311
1608
|
/** Consecutive {@link PENDING_REASON} skips — see {@link STALL_MARKER_FILE}. */
|
|
1312
1609
|
pendingSkips: number;
|
|
1610
|
+
/** Ticks sent as follow-ups but not yet observed by the agent loop. */
|
|
1611
|
+
pendingLocalTicks: PendingLocalTick[];
|
|
1612
|
+
/** The local tick whose agent loop is currently running, if any. */
|
|
1613
|
+
activeLocalTick?: ActiveLocalTick;
|
|
1313
1614
|
}
|
|
1314
1615
|
|
|
1315
1616
|
/**
|
|
@@ -1393,22 +1694,68 @@ function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSe
|
|
|
1393
1694
|
try {
|
|
1394
1695
|
const lastKey = store.lastDigestDedupeKey(scope.projectName);
|
|
1395
1696
|
const lastDay = lastKey === undefined ? undefined : lastKey.slice("digest:".length);
|
|
1697
|
+
const due = digestDue(policy ?? DEFAULT_REPORT_POLICY, lastDay, at);
|
|
1698
|
+
// Reserve availability-held rows before reading them. The same SQLite
|
|
1699
|
+
// transaction releases pending catch-ups; daemon enqueue attempts then
|
|
1700
|
+
// observe the lease, closing both orders of the snapshot-to-handoff race.
|
|
1701
|
+
const availabilityReserved =
|
|
1702
|
+
due &&
|
|
1703
|
+
store.reserveAvailabilityDigest(
|
|
1704
|
+
scope.projectName,
|
|
1705
|
+
localDayKey(at, digestPolicy.timezone ?? policy?.availability?.timezone),
|
|
1706
|
+
at,
|
|
1707
|
+
at +
|
|
1708
|
+
Math.max(
|
|
1709
|
+
MIN_AVAILABILITY_DIGEST_LEASE_MS,
|
|
1710
|
+
(config.budgetSeconds ?? DEFAULT_TICK_BUDGET_SECONDS) * 1_000 + 5 * 60_000,
|
|
1711
|
+
),
|
|
1712
|
+
);
|
|
1713
|
+
const backlog = store.digestBacklog(scope.projectName);
|
|
1714
|
+
const visibleBacklog: DigestBacklog = availabilityReserved
|
|
1715
|
+
? backlog
|
|
1716
|
+
: {
|
|
1717
|
+
...backlog,
|
|
1718
|
+
heldNoticeCount: Math.max(
|
|
1719
|
+
0,
|
|
1720
|
+
backlog.heldNoticeCount - (backlog.availabilityHeldNoticeCount ?? 0),
|
|
1721
|
+
),
|
|
1722
|
+
};
|
|
1396
1723
|
reportingConstraint = tickReportingConstraint(
|
|
1397
1724
|
policy,
|
|
1398
1725
|
{
|
|
1399
|
-
due
|
|
1726
|
+
due,
|
|
1400
1727
|
cadence,
|
|
1401
1728
|
at: digestPolicy.at,
|
|
1402
1729
|
timezone: digestPolicy.timezone,
|
|
1403
1730
|
},
|
|
1404
|
-
|
|
1731
|
+
visibleBacklog.heldNoticeCount,
|
|
1405
1732
|
);
|
|
1733
|
+
if (
|
|
1734
|
+
due &&
|
|
1735
|
+
(visibleBacklog.materialCount > 0 || visibleBacklog.heldNoticeCount > 0)
|
|
1736
|
+
) {
|
|
1737
|
+
const notices = store
|
|
1738
|
+
.undigestedNotices(scope.projectName)
|
|
1739
|
+
.filter((notice) => notice.releaseOnAvailable !== true || availabilityReserved);
|
|
1740
|
+
reportingConstraint +=
|
|
1741
|
+
`\n${formatDigestLedger(
|
|
1742
|
+
store.undigestedMaterialEvents(scope.projectName),
|
|
1743
|
+
notices,
|
|
1744
|
+
visibleBacklog,
|
|
1745
|
+
)}`;
|
|
1746
|
+
}
|
|
1406
1747
|
} finally {
|
|
1407
1748
|
store.close();
|
|
1408
1749
|
}
|
|
1409
1750
|
}
|
|
1410
1751
|
content = `${defaultTickMessage(new Date(), scope.briefPath, scope.policyPath)}\n${reportingConstraint}\n${bridged ? TICK_DELIVERY_RULE_BRIDGED : TICK_DELIVERY_RULE}`;
|
|
1411
1752
|
}
|
|
1753
|
+
if (scope.projectName !== undefined) {
|
|
1754
|
+
// Unlike ordinary reporting prose, this is a mechanical clock reading and
|
|
1755
|
+
// survives a custom heartbeat message. The model never infers whether the
|
|
1756
|
+
// operator can be interrupted.
|
|
1757
|
+
content = `${content}\n${availabilityPrompt(scope.policy, Date.now())}`;
|
|
1758
|
+
}
|
|
1412
1759
|
let frictionStore: Store | undefined;
|
|
1413
1760
|
let frictionSignals: FrictionSignal[] = [];
|
|
1414
1761
|
const now = Date.now();
|
|
@@ -1526,11 +1873,23 @@ function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSe
|
|
|
1526
1873
|
// No `accessFile` means no fleet bridge to judge, exactly as above.
|
|
1527
1874
|
if (profile?.kind === "interactive") content = `${content}\n${TICK_NARRATION_RULE}`;
|
|
1528
1875
|
|
|
1876
|
+
const pendingLocalTick: PendingLocalTick = {
|
|
1877
|
+
id: randomUUID(),
|
|
1878
|
+
...(scope.projectName === undefined ? {} : { projectName: scope.projectName }),
|
|
1879
|
+
...(scope.policy === undefined ? {} : { policy: scope.policy }),
|
|
1880
|
+
};
|
|
1881
|
+
session.pendingLocalTicks.push(pendingLocalTick);
|
|
1529
1882
|
try {
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1883
|
+
try {
|
|
1884
|
+
pi.sendMessage(
|
|
1885
|
+
{ customType: TICK_CUSTOM_TYPE, content, display: true, attribution: "user" },
|
|
1886
|
+
{ triggerTurn: true, deliverAs: "followUp" },
|
|
1887
|
+
);
|
|
1888
|
+
} catch (err) {
|
|
1889
|
+
const pendingIndex = session.pendingLocalTicks.indexOf(pendingLocalTick);
|
|
1890
|
+
if (pendingIndex >= 0) session.pendingLocalTicks.splice(pendingIndex, 1);
|
|
1891
|
+
throw err;
|
|
1892
|
+
}
|
|
1534
1893
|
if (scope.projectName !== undefined && frictionSignals.length > 0) {
|
|
1535
1894
|
try {
|
|
1536
1895
|
frictionStore?.markFrictionSurfaced(
|
|
@@ -1645,8 +2004,10 @@ export default function orchestratorTickExtension(pi: TickApi): void {
|
|
|
1645
2004
|
approvalToolMissingLogged: false,
|
|
1646
2005
|
bridgeTokenAtStart: true,
|
|
1647
2006
|
pendingSkips: 0,
|
|
2007
|
+
pendingLocalTicks: [],
|
|
1648
2008
|
};
|
|
1649
2009
|
let releaseGateArmed = false;
|
|
2010
|
+
let availabilityGateArmed = false;
|
|
1650
2011
|
let guardArmed = false;
|
|
1651
2012
|
// An activation file makes this a fleet directory before Herdr can prove
|
|
1652
2013
|
// which pane owns it. The gate therefore starts closed and only honours a
|
|
@@ -1716,6 +2077,61 @@ export default function orchestratorTickExtension(pi: TickApi): void {
|
|
|
1716
2077
|
});
|
|
1717
2078
|
};
|
|
1718
2079
|
|
|
2080
|
+
const armAvailabilityGate = (): void => {
|
|
2081
|
+
if (availabilityGateArmed) return;
|
|
2082
|
+
availabilityGateArmed = true;
|
|
2083
|
+
|
|
2084
|
+
// Custom messages sent with `pi.sendMessage` bypass `before_agent_start`.
|
|
2085
|
+
// `message_start` is the first lifecycle event that carries their exact
|
|
2086
|
+
// custom type, so it is the reliable boundary between a local tick and an
|
|
2087
|
+
// inbound human turn.
|
|
2088
|
+
pi.on("message_start", (event) => {
|
|
2089
|
+
const message = event.message;
|
|
2090
|
+
if (message.role === "custom" && message.customType === TICK_CUSTOM_TYPE) {
|
|
2091
|
+
const pending = session.pendingLocalTicks.shift();
|
|
2092
|
+
if (pending !== undefined) {
|
|
2093
|
+
session.activeLocalTick = { ...pending, humanWaiting: false };
|
|
2094
|
+
} else {
|
|
2095
|
+
// A queued custom tick can survive a host restart after the in-memory
|
|
2096
|
+
// enqueue record does not. Reconstruct its scope so recovery cannot
|
|
2097
|
+
// silently turn an autonomous run into an interactive one.
|
|
2098
|
+
const scope = resolveTickScope();
|
|
2099
|
+
const id =
|
|
2100
|
+
typeof message.timestamp === "number"
|
|
2101
|
+
? createHash("sha256").update(`recovered-tick\0${message.timestamp}`).digest("hex")
|
|
2102
|
+
: randomUUID();
|
|
2103
|
+
session.activeLocalTick = {
|
|
2104
|
+
id,
|
|
2105
|
+
...(scope.projectName === undefined ? {} : { projectName: scope.projectName }),
|
|
2106
|
+
...(scope.policy === undefined ? {} : { policy: scope.policy }),
|
|
2107
|
+
humanWaiting: false,
|
|
2108
|
+
};
|
|
2109
|
+
}
|
|
2110
|
+
return;
|
|
2111
|
+
}
|
|
2112
|
+
if (
|
|
2113
|
+
session.activeLocalTick !== undefined &&
|
|
2114
|
+
message.role === "user" &&
|
|
2115
|
+
message.synthetic !== true &&
|
|
2116
|
+
message.attribution !== "agent"
|
|
2117
|
+
) {
|
|
2118
|
+
session.activeLocalTick.humanWaiting = true;
|
|
2119
|
+
}
|
|
2120
|
+
});
|
|
2121
|
+
pi.on("agent_end", () => {
|
|
2122
|
+
session.activeLocalTick = undefined;
|
|
2123
|
+
});
|
|
2124
|
+
(pi as TickApi & {
|
|
2125
|
+
on(
|
|
2126
|
+
event: "tool_call",
|
|
2127
|
+
handler: (
|
|
2128
|
+
event: { toolName: string; input: Record<string, unknown> },
|
|
2129
|
+
ctx: unknown,
|
|
2130
|
+
) => TelegramInterruptBlock | undefined,
|
|
2131
|
+
): void;
|
|
2132
|
+
}).on("tool_call", (event) => autonomousTelegramInterruptBlock(pi, session, event));
|
|
2133
|
+
};
|
|
2134
|
+
|
|
1719
2135
|
pi.on("session_start", (_event, ctx) => {
|
|
1720
2136
|
if (decided) return;
|
|
1721
2137
|
|
|
@@ -1743,6 +2159,7 @@ export default function orchestratorTickExtension(pi: TickApi): void {
|
|
|
1743
2159
|
// Present but invalid still identifies a fleet directory. Install the
|
|
1744
2160
|
// fail-closed handler before validation or ownership can return early.
|
|
1745
2161
|
armReleaseGate();
|
|
2162
|
+
armAvailabilityGate();
|
|
1746
2163
|
|
|
1747
2164
|
if (result.kind === "invalid") {
|
|
1748
2165
|
const detail = `${result.path}: ${result.problem}`;
|