omp-conductor 0.12.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 +253 -84
- package/package.json +1 -1
- package/src/availability.ts +165 -0
- package/src/board.ts +1 -1
- package/src/briefs/orchestrator.md +95 -28
- package/src/briefs/policy.md +44 -32
- package/src/cli.ts +226 -37
- package/src/config.ts +123 -7
- package/src/daemon.ts +1018 -145
- package/src/diff-flags.ts +77 -4
- package/src/digest-schedule.ts +92 -24
- package/src/escalate.ts +46 -19
- package/src/failure-class.ts +7 -5
- package/src/fleet.ts +39 -3
- package/src/omp.ts +8 -4
- package/src/orchestrator-tick.ts +471 -39
- package/src/orchestrator.ts +3 -2
- package/src/plugin.ts +166 -14
- package/src/release-policy.ts +66 -6
- package/src/reports.ts +202 -5
- package/src/session-host.ts +4 -3
- package/src/setup.ts +197 -35
- package/src/store.ts +785 -112
- package/src/tracker/github.ts +299 -56
- package/src/types.ts +289 -37
- package/src/verbs/actions.ts +245 -15
- package/src/verbs/protocol.ts +7 -6
- package/src/verbs/server.ts +94 -13
- package/src/worker.ts +51 -11
- package/src/worktree.ts +5 -0
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,10 +60,12 @@ import {
|
|
|
58
60
|
import {
|
|
59
61
|
briefPathForProject,
|
|
60
62
|
policyPathForProject,
|
|
63
|
+
reportScopeFromPolicy,
|
|
61
64
|
refreshComposedBriefForProject,
|
|
62
65
|
} from "./setup.ts";
|
|
63
66
|
import {
|
|
64
67
|
recordReleaseBlock,
|
|
68
|
+
redactReleaseArgs,
|
|
65
69
|
releaseDriftDigestLine,
|
|
66
70
|
releaseRefusal,
|
|
67
71
|
releaseShapeFromTool,
|
|
@@ -72,8 +76,11 @@ import {
|
|
|
72
76
|
DEFAULT_REPORT_SCOPE,
|
|
73
77
|
DENIED_RELEASE_GRANTS,
|
|
74
78
|
type DispatchSummary,
|
|
79
|
+
type DigestBacklog,
|
|
75
80
|
type FrictionSignal,
|
|
76
|
-
type
|
|
81
|
+
type HeldNotice,
|
|
82
|
+
type MaterialEvent,
|
|
83
|
+
type ReportScopeChoice,
|
|
77
84
|
type ReportingPolicy,
|
|
78
85
|
type ResolvedGrants,
|
|
79
86
|
type Store,
|
|
@@ -81,7 +88,7 @@ import {
|
|
|
81
88
|
import { formatDecisionDigest } from "./decisions.ts";
|
|
82
89
|
import type { RunRecord } from "./types.ts";
|
|
83
90
|
import { dbPath, openStore } from "./store.ts";
|
|
84
|
-
import { digestDue } from "./digest-schedule.ts";
|
|
91
|
+
import { digestDue, localDayKey } from "./digest-schedule.ts";
|
|
85
92
|
|
|
86
93
|
/** The activation file. Absent means "this is not an orchestrator session". */
|
|
87
94
|
export const TICK_CONFIG_FILE = ".conductor-tick.json";
|
|
@@ -110,6 +117,8 @@ const FRICTION_DIGEST_LIMIT = 3;
|
|
|
110
117
|
* definitive, either way.
|
|
111
118
|
*/
|
|
112
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;
|
|
113
122
|
|
|
114
123
|
/**
|
|
115
124
|
* The stall marker — written beside the activation file, in the session cwd —
|
|
@@ -257,6 +266,23 @@ interface TickApi {
|
|
|
257
266
|
on(event: "session_start", handler: (event: { type: "session_start" }, ctx: TickContext) => void): void;
|
|
258
267
|
on(event: "turn_start", handler: (event: { type: "turn_start" }, ctx: TickContext) => void): void;
|
|
259
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;
|
|
260
286
|
/**
|
|
261
287
|
* `deliverAs: "followUp"` + `triggerTurn: true`, verified against
|
|
262
288
|
* `AgentSession.sendCustomMessage` rather than assumed:
|
|
@@ -409,42 +435,99 @@ export function queueDigestLine(
|
|
|
409
435
|
return line;
|
|
410
436
|
}
|
|
411
437
|
|
|
412
|
-
export const TICK_SCOPE_CONSTRAINTS: { readonly [K in
|
|
438
|
+
export const TICK_SCOPE_CONSTRAINTS: { readonly [K in ReportScopeChoice]: string } = {
|
|
413
439
|
material: "Report material events per your brief.",
|
|
414
440
|
escalations:
|
|
415
|
-
"
|
|
441
|
+
"Interrupt only for: tier2, fleet-stopped; everything else — releases included — waits for the daily digest.",
|
|
416
442
|
decisions:
|
|
417
|
-
"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.",
|
|
418
446
|
};
|
|
419
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
|
+
|
|
420
490
|
/**
|
|
421
|
-
* The reporting constraint appended to a default tick prompt (#229).
|
|
491
|
+
* The reporting constraint appended to a default tick prompt (#229, #242).
|
|
422
492
|
*
|
|
423
|
-
*
|
|
424
|
-
*
|
|
425
|
-
*
|
|
426
|
-
*
|
|
427
|
-
* categories and stating that everything else accumulates for the digest.
|
|
493
|
+
* Material and decisions keep their legacy preset words. The escalations
|
|
494
|
+
* preset instead names the policy's actual interrupt categories, so the prompt
|
|
495
|
+
* cannot drift from the gate. Every daily cadence gets an explicit due state:
|
|
496
|
+
* model-timed digests need that instruction just as scheduled digests do.
|
|
428
497
|
*/
|
|
429
498
|
export function tickReportingConstraint(
|
|
430
499
|
policy: ReportingPolicy | undefined,
|
|
431
|
-
digest: {
|
|
500
|
+
digest: {
|
|
501
|
+
due: boolean;
|
|
502
|
+
cadence: ReportingPolicy["digest"]["cadence"];
|
|
503
|
+
at?: string;
|
|
504
|
+
timezone?: string;
|
|
505
|
+
},
|
|
432
506
|
held: number,
|
|
433
507
|
): string {
|
|
434
508
|
const preset = policy?.scopePreset;
|
|
509
|
+
const allowed = policy?.interruptOn ?? [];
|
|
435
510
|
let base: string;
|
|
436
|
-
if (preset
|
|
511
|
+
if (preset === "escalations") {
|
|
512
|
+
base = `Interrupt only for: ${allowed.join(", ")}; everything else — releases included — waits for the daily digest.`;
|
|
513
|
+
} else if (preset !== undefined) {
|
|
437
514
|
base = TICK_SCOPE_CONSTRAINTS[preset];
|
|
438
515
|
} else {
|
|
439
|
-
const allowed = policy?.interruptOn ?? [];
|
|
440
516
|
base =
|
|
441
517
|
allowed.length === 0
|
|
442
518
|
? "Report nothing that would interrupt this turn; everything else accumulates for the digest."
|
|
443
519
|
: `Interrupt only for: ${allowed.join(", ")}. Everything else accumulates for the digest.`;
|
|
444
520
|
}
|
|
445
|
-
if (!
|
|
446
|
-
|
|
447
|
-
|
|
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
|
+
}
|
|
525
|
+
if (digest.cadence !== "daily") return base;
|
|
526
|
+
if (digest.due) {
|
|
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.`;
|
|
528
|
+
}
|
|
529
|
+
return digest.at === undefined
|
|
530
|
+
? `${base} The daily digest was already sent today; do not send another.`
|
|
448
531
|
: `${base} The daily digest is not due (scheduled ${digest.at}${digest.timezone === undefined ? "" : ` ${digest.timezone}`}); do not send one.`;
|
|
449
532
|
}
|
|
450
533
|
|
|
@@ -479,13 +562,14 @@ export function tickReportingConstraint(
|
|
|
479
562
|
* report is indistinguishable from a quiet tick. #123 moved delivery to the
|
|
480
563
|
* daemon, so the clause now names the handover instead. `omp-conductor report`
|
|
481
564
|
* persists the text before anything is sent and the daemon retries it until it
|
|
482
|
-
* lands; a handover that returns a report id has
|
|
483
|
-
* the failure mode this rule exists for, which a
|
|
484
|
-
* could. `telegram_send` remains the right call
|
|
485
|
-
* 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.
|
|
486
570
|
*/
|
|
487
571
|
export const TICK_DELIVERY_RULE =
|
|
488
|
-
"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.";
|
|
489
573
|
|
|
490
574
|
/** The {@link TICK_DELIVERY_RULE} variant for a fleet whose bridge actually
|
|
491
575
|
* delivers the tick's ending text (#169). That is a narrower class than the
|
|
@@ -528,8 +612,9 @@ export { TELEGRAM_APPROVAL_TOOL };
|
|
|
528
612
|
*/
|
|
529
613
|
export const TICK_APPROVAL_UNAVAILABLE_RULE =
|
|
530
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. ` +
|
|
531
|
-
`If you have an amendment to propose, deliver the question with telegram_send and wait for your operator's reply on a later turn
|
|
532
|
-
`
|
|
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.`;
|
|
533
618
|
|
|
534
619
|
/**
|
|
535
620
|
* Appended to every tick — the shipped prompt or the operator's own — composed
|
|
@@ -615,7 +700,7 @@ export function formatFrictionDigest(signals: readonly FrictionSignal[]): string
|
|
|
615
700
|
* trade.
|
|
616
701
|
*/
|
|
617
702
|
export function resolveTickScope(): {
|
|
618
|
-
scope:
|
|
703
|
+
scope: ReportScopeChoice;
|
|
619
704
|
policy?: ReportingPolicy;
|
|
620
705
|
briefPath?: string;
|
|
621
706
|
policyPath?: string;
|
|
@@ -625,7 +710,7 @@ export function resolveTickScope(): {
|
|
|
625
710
|
try {
|
|
626
711
|
const project = findProject(loadConfig());
|
|
627
712
|
return {
|
|
628
|
-
scope: project.reporting
|
|
713
|
+
scope: reportScopeFromPolicy(project.reporting),
|
|
629
714
|
policy: project.reporting,
|
|
630
715
|
briefPath: briefPathForProject(project),
|
|
631
716
|
policyPath: policyPathForProject(project),
|
|
@@ -1272,6 +1357,228 @@ function clearTickRequest(pi: TickApi, cwd: string): void {
|
|
|
1272
1357
|
}
|
|
1273
1358
|
}
|
|
1274
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
|
+
|
|
1275
1582
|
/** Everything one tick remembers for the next. */
|
|
1276
1583
|
interface TickSession {
|
|
1277
1584
|
/**
|
|
@@ -1300,6 +1607,10 @@ interface TickSession {
|
|
|
1300
1607
|
bridgeTokenAtStart: boolean;
|
|
1301
1608
|
/** Consecutive {@link PENDING_REASON} skips — see {@link STALL_MARKER_FILE}. */
|
|
1302
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;
|
|
1303
1614
|
}
|
|
1304
1615
|
|
|
1305
1616
|
/**
|
|
@@ -1377,28 +1688,74 @@ function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSe
|
|
|
1377
1688
|
if (scope.projectName !== undefined) {
|
|
1378
1689
|
const policy = scope.policy;
|
|
1379
1690
|
const digestPolicy = policy?.digest ?? DEFAULT_REPORT_POLICY.digest;
|
|
1380
|
-
const
|
|
1691
|
+
const cadence = digestPolicy.cadence;
|
|
1381
1692
|
const at = Date.now();
|
|
1382
1693
|
const store = openStore(dbPath());
|
|
1383
1694
|
try {
|
|
1384
1695
|
const lastKey = store.lastDigestDedupeKey(scope.projectName);
|
|
1385
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
|
+
};
|
|
1386
1723
|
reportingConstraint = tickReportingConstraint(
|
|
1387
1724
|
policy,
|
|
1388
1725
|
{
|
|
1389
|
-
due
|
|
1390
|
-
|
|
1726
|
+
due,
|
|
1727
|
+
cadence,
|
|
1391
1728
|
at: digestPolicy.at,
|
|
1392
1729
|
timezone: digestPolicy.timezone,
|
|
1393
1730
|
},
|
|
1394
|
-
|
|
1731
|
+
visibleBacklog.heldNoticeCount,
|
|
1395
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
|
+
}
|
|
1396
1747
|
} finally {
|
|
1397
1748
|
store.close();
|
|
1398
1749
|
}
|
|
1399
1750
|
}
|
|
1400
1751
|
content = `${defaultTickMessage(new Date(), scope.briefPath, scope.policyPath)}\n${reportingConstraint}\n${bridged ? TICK_DELIVERY_RULE_BRIDGED : TICK_DELIVERY_RULE}`;
|
|
1401
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
|
+
}
|
|
1402
1759
|
let frictionStore: Store | undefined;
|
|
1403
1760
|
let frictionSignals: FrictionSignal[] = [];
|
|
1404
1761
|
const now = Date.now();
|
|
@@ -1516,11 +1873,23 @@ function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSe
|
|
|
1516
1873
|
// No `accessFile` means no fleet bridge to judge, exactly as above.
|
|
1517
1874
|
if (profile?.kind === "interactive") content = `${content}\n${TICK_NARRATION_RULE}`;
|
|
1518
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);
|
|
1519
1882
|
try {
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
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
|
+
}
|
|
1524
1893
|
if (scope.projectName !== undefined && frictionSignals.length > 0) {
|
|
1525
1894
|
try {
|
|
1526
1895
|
frictionStore?.markFrictionSurfaced(
|
|
@@ -1635,8 +2004,10 @@ export default function orchestratorTickExtension(pi: TickApi): void {
|
|
|
1635
2004
|
approvalToolMissingLogged: false,
|
|
1636
2005
|
bridgeTokenAtStart: true,
|
|
1637
2006
|
pendingSkips: 0,
|
|
2007
|
+
pendingLocalTicks: [],
|
|
1638
2008
|
};
|
|
1639
2009
|
let releaseGateArmed = false;
|
|
2010
|
+
let availabilityGateArmed = false;
|
|
1640
2011
|
let guardArmed = false;
|
|
1641
2012
|
// An activation file makes this a fleet directory before Herdr can prove
|
|
1642
2013
|
// which pane owns it. The gate therefore starts closed and only honours a
|
|
@@ -1683,9 +2054,17 @@ export default function orchestratorTickExtension(pi: TickApi): void {
|
|
|
1683
2054
|
// `createSession`; suppress this second copy only after this session has
|
|
1684
2055
|
// proved it owns the external heartbeat.
|
|
1685
2056
|
if (releaseAuthorityAccepted && !external) return undefined;
|
|
2057
|
+
// Before ownership is proved this session holds no grant at all, so a
|
|
2058
|
+
// covered shape still refuses with the deny-all wording.
|
|
2059
|
+
const decision = refusal ?? releaseRefusal(DENIED_RELEASE_GRANTS, "orchestrator", shape);
|
|
2060
|
+
if (decision === undefined) return undefined;
|
|
1686
2061
|
if (projectName !== undefined) {
|
|
1687
2062
|
try {
|
|
1688
|
-
recordReleaseBlock(projectName, "orchestrator", shape
|
|
2063
|
+
recordReleaseBlock(projectName, "orchestrator", shape, {
|
|
2064
|
+
tool: event.toolName,
|
|
2065
|
+
reason: decision.reason,
|
|
2066
|
+
args: redactReleaseArgs(event.input),
|
|
2067
|
+
});
|
|
1689
2068
|
} catch (err) {
|
|
1690
2069
|
pi.logger.error(
|
|
1691
2070
|
`[omp-conductor] could not record release-policy block: ${
|
|
@@ -1694,13 +2073,65 @@ export default function orchestratorTickExtension(pi: TickApi): void {
|
|
|
1694
2073
|
);
|
|
1695
2074
|
}
|
|
1696
2075
|
}
|
|
1697
|
-
|
|
1698
|
-
// covered shape still refuses — with the wording it would get from a
|
|
1699
|
-
// deny-all map rather than a claim about a grant it cannot yet use.
|
|
1700
|
-
return refusal ?? releaseRefusal(DENIED_RELEASE_GRANTS, "orchestrator", shape);
|
|
2076
|
+
return decision;
|
|
1701
2077
|
});
|
|
1702
2078
|
};
|
|
1703
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
|
+
|
|
1704
2135
|
pi.on("session_start", (_event, ctx) => {
|
|
1705
2136
|
if (decided) return;
|
|
1706
2137
|
|
|
@@ -1728,6 +2159,7 @@ export default function orchestratorTickExtension(pi: TickApi): void {
|
|
|
1728
2159
|
// Present but invalid still identifies a fleet directory. Install the
|
|
1729
2160
|
// fail-closed handler before validation or ownership can return early.
|
|
1730
2161
|
armReleaseGate();
|
|
2162
|
+
armAvailabilityGate();
|
|
1731
2163
|
|
|
1732
2164
|
if (result.kind === "invalid") {
|
|
1733
2165
|
const detail = `${result.path}: ${result.problem}`;
|