omp-conductor 0.13.0 → 0.15.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 +549 -234
- package/package.json +8 -5
- package/schema/config.schema.json +609 -0
- package/src/availability.ts +165 -0
- package/src/board.ts +19 -32
- package/src/brief-upgrade.ts +1 -1
- package/src/briefs/orchestrator.md +72 -31
- package/src/briefs/policy.md +48 -36
- package/src/briefs/probes/gates.md +51 -0
- package/src/briefs/probes/project-context.md +59 -0
- package/src/briefs/probes/release-procedure.md +81 -0
- package/src/cli.ts +356 -212
- package/src/config-schema.ts +352 -0
- package/src/config.ts +1037 -679
- package/src/confinement.ts +54 -0
- package/src/daemon.ts +644 -390
- package/src/diff-flags.ts +73 -4
- package/src/digest-schedule.ts +92 -24
- package/src/escalate.ts +89 -22
- package/src/fleet.ts +351 -46
- package/src/generate-schema.ts +21 -0
- package/src/graph.ts +3 -3
- package/src/host.ts +16 -0
- package/src/omp.ts +21 -1
- package/src/orchestrator-tick.ts +732 -56
- package/src/privileged.ts +264 -0
- package/src/reports.ts +203 -6
- package/src/session-host.ts +3 -0
- package/src/setup-host.ts +209 -24
- package/src/setup-install.ts +320 -0
- package/src/setup-probe.ts +412 -0
- package/src/setup-wizard.ts +1946 -0
- package/src/setup.ts +457 -53
- package/src/store.ts +610 -98
- package/src/tracker/github.ts +43 -5
- package/src/types.ts +153 -14
- package/src/upgrade.ts +44 -10
- package/src/verbs/actions.ts +131 -13
- package/src/verbs/server.ts +40 -18
- package/src/wizard-ui.ts +249 -0
- package/src/worker.ts +24 -7
- package/skills/conductor-onboarding/SKILL.md +0 -748
- package/skills/conductor-update/SKILL.md +0 -51
- package/src/plugin.ts +0 -1495
package/src/orchestrator-tick.ts
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
*
|
|
26
26
|
* Beyond those three gates, every tick carries the project's `reporting.scope`
|
|
27
27
|
* as one explicit constraint line, re-read from the conductor config on each
|
|
28
|
-
* tick so
|
|
28
|
+
* tick so an `omp-conductor setup` change binds the next heartbeat rather than
|
|
29
29
|
* waiting for a session restart — and one delivery rule
|
|
30
30
|
* ({@link TICK_DELIVERY_RULE}), because a tick is injected locally and a report
|
|
31
31
|
* written as end-of-turn text on such a turn reaches nobody. An operator's own
|
|
@@ -44,10 +44,12 @@
|
|
|
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";
|
|
50
|
-
import {
|
|
51
|
+
import { availabilityPrompt, interruptDisposition } from "./availability.ts";
|
|
52
|
+
import { findProject, loadConfig, resolveReleaseGrants, stateDir } from "./config.ts";
|
|
51
53
|
import {
|
|
52
54
|
bridgeTokenBound,
|
|
53
55
|
hasBotToken,
|
|
@@ -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 —
|
|
@@ -145,13 +153,18 @@ const PENDING_MESSAGE_GRACE_MS = 60_000;
|
|
|
145
153
|
const DEFAULT_GROOM_BELOW = 4;
|
|
146
154
|
|
|
147
155
|
/**
|
|
148
|
-
* Written by herdr-conductor `recover.sh` *before* `agent start`,
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
156
|
+
* Written by herdr-conductor `recover.sh` *before* `agent start`, and by the
|
|
157
|
+
* dispatch daemon when a watched decision condition transitions false→true
|
|
158
|
+
* (#329), so the orchestrator can reconcile without waiting a full
|
|
159
|
+
* `intervalSeconds`. Cleared only after a tick is actually sent — a disarmed or
|
|
160
|
+
* channel-down fleet keeps the request until gates pass (or a human removes the
|
|
161
|
+
* file). Mid-interval the heartbeat polls for this file on a short cadence.
|
|
152
162
|
*/
|
|
153
163
|
export const TICK_REQUESTED_FILE = ".conductor-tick-requested";
|
|
154
164
|
|
|
165
|
+
/** How often a live heartbeat looks for {@link TICK_REQUESTED_FILE} between ticks. */
|
|
166
|
+
const TICK_REQUEST_POLL_MS = 10_000;
|
|
167
|
+
|
|
155
168
|
/** Runtime heartbeat schedule consumed by `omp-conductor status`. */
|
|
156
169
|
export const TICK_STATUS_FILE = ".conductor-tick-status.json";
|
|
157
170
|
|
|
@@ -205,9 +218,9 @@ const PENDING_REASON = "tick already pending";
|
|
|
205
218
|
* The slice of the omp extension API this entry touches, mirroring
|
|
206
219
|
* `ExtensionAPI` / `ExtensionContext` from `@oh-my-pi/pi-coding-agent`.
|
|
207
220
|
*
|
|
208
|
-
* Declared here rather than imported
|
|
209
|
-
*
|
|
210
|
-
*
|
|
221
|
+
* Declared here rather than imported because the harness is a peer dependency and
|
|
222
|
+
* the package has to type-check without it installed — the same reason ./omp.ts
|
|
223
|
+
* loads the SDK dynamically. Structural typing means the real objects satisfy these
|
|
211
224
|
* on the way in.
|
|
212
225
|
*/
|
|
213
226
|
interface TickLogger {
|
|
@@ -258,6 +271,23 @@ interface TickApi {
|
|
|
258
271
|
on(event: "session_start", handler: (event: { type: "session_start" }, ctx: TickContext) => void): void;
|
|
259
272
|
on(event: "turn_start", handler: (event: { type: "turn_start" }, ctx: TickContext) => void): void;
|
|
260
273
|
on(event: "turn_end", handler: (event: { type: "turn_end" }, ctx: TickContext) => void): void;
|
|
274
|
+
on(
|
|
275
|
+
event: "message_start",
|
|
276
|
+
handler: (
|
|
277
|
+
event: {
|
|
278
|
+
type: "message_start";
|
|
279
|
+
message: {
|
|
280
|
+
role: string;
|
|
281
|
+
customType?: string;
|
|
282
|
+
synthetic?: boolean;
|
|
283
|
+
attribution?: "user" | "agent";
|
|
284
|
+
timestamp?: number;
|
|
285
|
+
};
|
|
286
|
+
},
|
|
287
|
+
ctx: TickContext,
|
|
288
|
+
) => void,
|
|
289
|
+
): void;
|
|
290
|
+
on(event: "agent_end", handler: (event: { type: "agent_end" }, ctx: TickContext) => void): void;
|
|
261
291
|
/**
|
|
262
292
|
* `deliverAs: "followUp"` + `triggerTurn: true`, verified against
|
|
263
293
|
* `AgentSession.sendCustomMessage` rather than assumed:
|
|
@@ -287,6 +317,19 @@ interface TickApi {
|
|
|
287
317
|
* they get here. */
|
|
288
318
|
export interface TickConfig {
|
|
289
319
|
intervalSeconds: number;
|
|
320
|
+
/**
|
|
321
|
+
* The conductor project this fleet session ticks for, stamped by `setup host`
|
|
322
|
+
* (one tick config per fleet cwd). It is what lets a host with several
|
|
323
|
+
* configured projects resolve *this* fleet's brief, reporting policy, digest
|
|
324
|
+
* ledger and release grants, instead of asking `findProject` a question it
|
|
325
|
+
* refuses to guess through.
|
|
326
|
+
*
|
|
327
|
+
* Omitted is the pre-multi-project spelling. Resolution then falls back to the
|
|
328
|
+
* un-named `findProject`: exact for a single-project fleet — which must keep
|
|
329
|
+
* behaving exactly as it did — and ambiguous, therefore degrading, for more
|
|
330
|
+
* than one.
|
|
331
|
+
*/
|
|
332
|
+
project?: string;
|
|
290
333
|
armedFile?: string;
|
|
291
334
|
accessFile?: string;
|
|
292
335
|
message?: string;
|
|
@@ -410,14 +453,58 @@ export function queueDigestLine(
|
|
|
410
453
|
return line;
|
|
411
454
|
}
|
|
412
455
|
|
|
413
|
-
export const TICK_SCOPE_CONSTRAINTS: { readonly [K in
|
|
456
|
+
export const TICK_SCOPE_CONSTRAINTS: { readonly [K in ReportScopeChoice]: string } = {
|
|
414
457
|
material: "Report material events per your brief.",
|
|
415
458
|
escalations:
|
|
416
459
|
"Interrupt only for: tier2, fleet-stopped; everything else — releases included — waits for the daily digest.",
|
|
417
460
|
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
|
|
461
|
+
"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.",
|
|
462
|
+
quiet:
|
|
463
|
+
"Interrupt only for: tier2, fleet-stopped, confirmed-failure; everything else waits for the daily digest.",
|
|
419
464
|
};
|
|
420
465
|
|
|
466
|
+
/** Bounded, durable source material for one due digest. Row ids are part of the
|
|
467
|
+
* handoff contract: the report and exactly the rows it consumed settle in one
|
|
468
|
+
* SQLite transaction, so a crash cannot lose an outcome between those writes. */
|
|
469
|
+
export function formatDigestLedger(
|
|
470
|
+
events: readonly MaterialEvent[],
|
|
471
|
+
notices: readonly HeldNotice[],
|
|
472
|
+
backlog: DigestBacklog,
|
|
473
|
+
): string {
|
|
474
|
+
const oneLine = (text: string, limit: number): string => {
|
|
475
|
+
const flat = text.replace(/\s+/g, " ").trim();
|
|
476
|
+
return flat.length <= limit ? flat : `${flat.slice(0, limit - 1)}…`;
|
|
477
|
+
};
|
|
478
|
+
const lines = [
|
|
479
|
+
`Durable digest ledger — ${backlog.materialCount} material event(s), ${backlog.heldNoticeCount} held escalation(s); oldest first:`,
|
|
480
|
+
...events.map(
|
|
481
|
+
(event) =>
|
|
482
|
+
` event ${event.id} | ${new Date(event.occurredAt).toISOString()} | ${event.category} | ` +
|
|
483
|
+
`${oneLine(event.summary, 180)} | evidence: ${oneLine(event.evidence, 240)}`,
|
|
484
|
+
),
|
|
485
|
+
...notices.map(
|
|
486
|
+
(notice) =>
|
|
487
|
+
` notice ${notice.id} | ${new Date(notice.createdAt).toISOString()} | ${notice.category} | ` +
|
|
488
|
+
`${oneLine(notice.summary, 180)} | detail: ${oneLine(notice.detail, 240)}`,
|
|
489
|
+
),
|
|
490
|
+
];
|
|
491
|
+
if (backlog.materialCount > events.length) {
|
|
492
|
+
lines.push(` … ${backlog.materialCount - events.length} newer material event(s) remain owed.`);
|
|
493
|
+
}
|
|
494
|
+
if (backlog.heldNoticeCount > notices.length) {
|
|
495
|
+
lines.push(` … ${backlog.heldNoticeCount - notices.length} additional held escalation(s) remain owed.`);
|
|
496
|
+
}
|
|
497
|
+
const eventIds = events.map((event) => event.id).join(",");
|
|
498
|
+
const noticeIds = notices.map((notice) => notice.id).join(",");
|
|
499
|
+
lines.push(
|
|
500
|
+
"Compose the digest from the relevant rows above. Hand it off with omp-conductor report --kind digest " +
|
|
501
|
+
`${eventIds.length === 0 ? "" : `--events ${eventIds} `}` +
|
|
502
|
+
`${noticeIds.length === 0 ? "" : `--notices ${noticeIds} `}` +
|
|
503
|
+
"--text TEXT. Remove any row id you did not actually include; omitted rows remain owed. Never invent an id.",
|
|
504
|
+
);
|
|
505
|
+
return lines.join("\n");
|
|
506
|
+
}
|
|
507
|
+
|
|
421
508
|
/**
|
|
422
509
|
* The reporting constraint appended to a default tick prompt (#229, #242).
|
|
423
510
|
*
|
|
@@ -449,9 +536,13 @@ export function tickReportingConstraint(
|
|
|
449
536
|
? "Report nothing that would interrupt this turn; everything else accumulates for the digest."
|
|
450
537
|
: `Interrupt only for: ${allowed.join(", ")}. Everything else accumulates for the digest.`;
|
|
451
538
|
}
|
|
539
|
+
if (policy !== undefined && !allowed.includes("material")) {
|
|
540
|
+
base +=
|
|
541
|
+
" 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.";
|
|
542
|
+
}
|
|
452
543
|
if (digest.cadence !== "daily") return base;
|
|
453
544
|
if (digest.due) {
|
|
454
|
-
return `${base} The daily digest is DUE now — compose it from
|
|
545
|
+
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
546
|
}
|
|
456
547
|
return digest.at === undefined
|
|
457
548
|
? `${base} The daily digest was already sent today; do not send another.`
|
|
@@ -489,13 +580,14 @@ export function tickReportingConstraint(
|
|
|
489
580
|
* report is indistinguishable from a quiet tick. #123 moved delivery to the
|
|
490
581
|
* daemon, so the clause now names the handover instead. `omp-conductor report`
|
|
491
582
|
* 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
|
|
583
|
+
* lands; a handover that returns a durable report or held-notice id has
|
|
584
|
+
* therefore *already* survived the failure mode this rule exists for, which a
|
|
585
|
+
* watched `telegram_send` never could. `telegram_send` remains the right call
|
|
586
|
+
* for an interactive reply to a person who is waiting — that is a
|
|
587
|
+
* conversation, not a report.
|
|
496
588
|
*/
|
|
497
589
|
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.";
|
|
590
|
+
"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
591
|
|
|
500
592
|
/** The {@link TICK_DELIVERY_RULE} variant for a fleet whose bridge actually
|
|
501
593
|
* delivers the tick's ending text (#169). That is a narrower class than the
|
|
@@ -538,8 +630,9 @@ export { TELEGRAM_APPROVAL_TOOL };
|
|
|
538
630
|
*/
|
|
539
631
|
export const TICK_APPROVAL_UNAVAILABLE_RULE =
|
|
540
632
|
`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
|
-
`
|
|
633
|
+
`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. ` +
|
|
634
|
+
`A returned telegram_ask answer proves an answer, not Telegram delivery. ` +
|
|
635
|
+
`Never apply an amendment, or record one as approved, without an explicit answer you actually received.`;
|
|
543
636
|
|
|
544
637
|
/**
|
|
545
638
|
* Appended to every tick — the shipped prompt or the operator's own — composed
|
|
@@ -607,7 +700,7 @@ export function formatFrictionDigest(signals: readonly FrictionSignal[]): string
|
|
|
607
700
|
* config could not answer — why.
|
|
608
701
|
*
|
|
609
702
|
* Read on every tick rather than cached at session start, for the reason the
|
|
610
|
-
* channel gate is: the operator re-runs
|
|
703
|
+
* channel gate is: the operator re-runs `omp-conductor setup` while this session
|
|
611
704
|
* lives, and a heartbeat holding a startup snapshot would keep injecting the
|
|
612
705
|
* old contract until somebody restarted it.
|
|
613
706
|
*
|
|
@@ -623,27 +716,141 @@ export function formatFrictionDigest(signals: readonly FrictionSignal[]): string
|
|
|
623
716
|
* none named — the same ambiguity `findProject` refuses to guess through for
|
|
624
717
|
* `status`. Stopping the heartbeat over either preference would be the worse
|
|
625
718
|
* trade.
|
|
719
|
+
*
|
|
720
|
+
* `projectName` is {@link TickConfig.project}, stamped per fleet cwd, and it is
|
|
721
|
+
* the whole difference between a two-project host resolving each fleet's own
|
|
722
|
+
* contract and both of them degrading. Omitted (a pre-multi-project tick config)
|
|
723
|
+
* keeps the un-named lookup, which only a host with more than one project can
|
|
724
|
+
* fail — and that host gets {@link LEGACY_TICK_PROJECT_HINT} naming the one
|
|
725
|
+
* command that fixes it.
|
|
626
726
|
*/
|
|
627
|
-
export function resolveTickScope(): {
|
|
628
|
-
scope:
|
|
727
|
+
export function resolveTickScope(projectName?: string): {
|
|
728
|
+
scope: ReportScopeChoice;
|
|
629
729
|
policy?: ReportingPolicy;
|
|
630
730
|
briefPath?: string;
|
|
631
731
|
policyPath?: string;
|
|
632
732
|
projectName?: string;
|
|
633
733
|
fallback?: string;
|
|
634
734
|
} {
|
|
735
|
+
// Counted before `findProject` can throw, because "several projects and this
|
|
736
|
+
// tick config names none" is the one fault with a one-command remedy and the
|
|
737
|
+
// only way to tell it apart from "no config at all" is the count.
|
|
738
|
+
let projects = 0;
|
|
635
739
|
try {
|
|
636
|
-
const
|
|
740
|
+
const config = loadConfig();
|
|
741
|
+
projects = config.projects.length;
|
|
742
|
+
const project = findProject(config, projectName);
|
|
637
743
|
return {
|
|
638
|
-
scope: project.reporting
|
|
744
|
+
scope: reportScopeFromPolicy(project.reporting),
|
|
639
745
|
policy: project.reporting,
|
|
640
746
|
briefPath: briefPathForProject(project),
|
|
641
747
|
policyPath: policyPathForProject(project),
|
|
642
748
|
projectName: project.name,
|
|
643
749
|
};
|
|
644
750
|
} catch (err) {
|
|
645
|
-
|
|
751
|
+
const problem = err instanceof Error ? err.message : String(err);
|
|
752
|
+
const unstamped = projectName === undefined && projects > 1;
|
|
753
|
+
return { scope: DEFAULT_REPORT_SCOPE, fallback: unstamped ? `${problem} ${LEGACY_TICK_PROJECT_HINT}` : problem };
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/**
|
|
758
|
+
* The fix for the one degradation an operator can clear in a single command: a
|
|
759
|
+
* tick config written before {@link TickConfig.project} existed, on a host that
|
|
760
|
+
* has since gained a second project.
|
|
761
|
+
*/
|
|
762
|
+
export const LEGACY_TICK_PROJECT_HINT =
|
|
763
|
+
"Re-run `omp-conductor setup host` to stamp the project into .conductor-tick.json.";
|
|
764
|
+
|
|
765
|
+
/**
|
|
766
|
+
* Whether a tick config found on disk belongs to the project being asked about.
|
|
767
|
+
*
|
|
768
|
+
* Search roots overlap — `stateDir()` and the shared parent of two fleet cwds
|
|
769
|
+
* are read for every project — so without this a stray config in a shared root
|
|
770
|
+
* answers for whichever project asked first, and `setup host` would restamp one
|
|
771
|
+
* project's file while planning another. An unstamped config matches anything,
|
|
772
|
+
* which is what keeps a single-project fleet on its existing file.
|
|
773
|
+
*/
|
|
774
|
+
export function tickConfigMatchesProject(config: TickConfig, projectName?: string): boolean {
|
|
775
|
+
if (projectName === undefined || config.project === undefined) return true;
|
|
776
|
+
return config.project === projectName;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/**
|
|
780
|
+
* Directory that owns this project's {@link TICK_CONFIG_FILE}, if one is
|
|
781
|
+
* present and stamped for the project (or unstamped on a single-project host).
|
|
782
|
+
* Used by out-of-process writers of {@link TICK_REQUESTED_FILE} — the dispatch
|
|
783
|
+
* daemon must not import `fleet.ts` (fleet already imports daemon).
|
|
784
|
+
*/
|
|
785
|
+
export function resolveTickConfigCwd(projectName?: string): string | undefined {
|
|
786
|
+
const roots: string[] = [stateDir()];
|
|
787
|
+
try {
|
|
788
|
+
const p = findProject(loadConfig(), projectName);
|
|
789
|
+
const parent = dirname(p.workspaceRoot);
|
|
790
|
+
if (parent !== roots[0]) roots.push(parent);
|
|
791
|
+
if (p.workspaceRoot !== roots[0] && p.workspaceRoot !== parent) roots.push(p.workspaceRoot);
|
|
792
|
+
} catch {
|
|
793
|
+
/* no config */
|
|
794
|
+
}
|
|
795
|
+
for (const cwd of roots) {
|
|
796
|
+
const r = readTickConfig(cwd);
|
|
797
|
+
if (r.kind === "ok") {
|
|
798
|
+
if (!tickConfigMatchesProject(r.config, projectName)) continue;
|
|
799
|
+
return cwd;
|
|
800
|
+
}
|
|
801
|
+
if (r.kind === "invalid") return undefined;
|
|
802
|
+
}
|
|
803
|
+
return undefined;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/** The shared, pre-per-project arm marker: `<stateDir>/armed`. */
|
|
807
|
+
export function legacyArmedMarkerPath(): string {
|
|
808
|
+
return join(stateDir(), "armed");
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/** What `status` says about a bare `armed` marker that no tick will honour. */
|
|
812
|
+
export const LEGACY_ARM_MARKER_DETAIL =
|
|
813
|
+
"legacy global arm marker — re-run setup host, then arm per project";
|
|
814
|
+
|
|
815
|
+
/**
|
|
816
|
+
* Whether the arm gate is open, and whether the shared pre-per-project marker is
|
|
817
|
+
* what decided it.
|
|
818
|
+
*
|
|
819
|
+
* Two migrations meet here. A single-project fleet whose `armedFile` has just
|
|
820
|
+
* been restamped from `armed` to `armed-<name>` must not be silently disarmed by
|
|
821
|
+
* the upgrade, so its bare `armed` marker still counts (`legacy: "honoured"`). A
|
|
822
|
+
* host with more than one project cannot let one marker arm every fleet — that is
|
|
823
|
+
* the collision this issue removes — so there the bare marker arms nothing and
|
|
824
|
+
* says so instead (`legacy: "stranded"`), including for a tick config that still
|
|
825
|
+
* names it directly.
|
|
826
|
+
*
|
|
827
|
+
* The fallback reaches exactly two `armedFile` spellings: the shared path itself,
|
|
828
|
+
* and the per-project path the restamp replaces it with. An operator's own gate
|
|
829
|
+
* — any other value, which `setup host` deliberately preserves — is the gate; its
|
|
830
|
+
* absence means disarmed however many stale markers lie around it.
|
|
831
|
+
*
|
|
832
|
+
* The config is read only when a legacy marker is actually in play, so the
|
|
833
|
+
* healthy path costs one string compare. An unreadable or absent config is
|
|
834
|
+
* treated as single-project: a host with no conductor config cannot be a
|
|
835
|
+
* multi-project host, and 0.14 behaviour is the honest default there.
|
|
836
|
+
*/
|
|
837
|
+
export type ArmState = { armed: boolean; legacy?: "honoured" | "stranded" };
|
|
838
|
+
|
|
839
|
+
export function resolveArmState(armedFile: string, projectName?: string): ArmState {
|
|
840
|
+
const legacy = legacyArmedMarkerPath();
|
|
841
|
+
const shared = armedFile === legacy;
|
|
842
|
+
if (!shared && existsSync(armedFile)) return { armed: true };
|
|
843
|
+
const migratable =
|
|
844
|
+
shared || (projectName !== undefined && armedFile === join(stateDir(), `armed-${projectName}`));
|
|
845
|
+
if (!migratable || !existsSync(legacy)) return { armed: false };
|
|
846
|
+
let multiProject = false;
|
|
847
|
+
try {
|
|
848
|
+
multiProject = loadConfig().projects.length > 1;
|
|
849
|
+
} catch {
|
|
850
|
+
// No readable config: not a multi-project host, so keep 0.14 behaviour.
|
|
646
851
|
}
|
|
852
|
+
if (multiProject) return { armed: false, legacy: "stranded" };
|
|
853
|
+
return shared ? { armed: true } : { armed: true, legacy: "honoured" };
|
|
647
854
|
}
|
|
648
855
|
|
|
649
856
|
/**
|
|
@@ -655,9 +862,9 @@ export function resolveTickScope(): {
|
|
|
655
862
|
* the default prompt path. Failures (no config, no `POLICY.md`, unreadable
|
|
656
863
|
* overlay) are silent: the tick still goes out.
|
|
657
864
|
*/
|
|
658
|
-
export function refreshComposedBriefBestEffort(): boolean {
|
|
865
|
+
export function refreshComposedBriefBestEffort(projectName?: string): boolean {
|
|
659
866
|
try {
|
|
660
|
-
return refreshComposedBriefForProject(findProject(loadConfig()));
|
|
867
|
+
return refreshComposedBriefForProject(findProject(loadConfig(), projectName));
|
|
661
868
|
} catch {
|
|
662
869
|
return false;
|
|
663
870
|
}
|
|
@@ -747,6 +954,16 @@ export function readTickConfig(cwd: string): TickConfigResult {
|
|
|
747
954
|
}
|
|
748
955
|
}
|
|
749
956
|
|
|
957
|
+
const projectRaw = raw["project"];
|
|
958
|
+
let project: string | undefined;
|
|
959
|
+
if (projectRaw !== undefined) {
|
|
960
|
+
if (typeof projectRaw !== "string" || projectRaw.trim().length === 0) {
|
|
961
|
+
problems.push("project must be a non-empty string when present");
|
|
962
|
+
} else {
|
|
963
|
+
project = projectRaw.trim();
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
|
|
750
967
|
if (problems.length > 0) return { kind: "invalid", path, problem: problems.join("; ") };
|
|
751
968
|
|
|
752
969
|
return {
|
|
@@ -754,6 +971,7 @@ export function readTickConfig(cwd: string): TickConfigResult {
|
|
|
754
971
|
path,
|
|
755
972
|
config: {
|
|
756
973
|
intervalSeconds,
|
|
974
|
+
...(project === undefined ? {} : { project }),
|
|
757
975
|
...(budgetSeconds === undefined ? {} : { budgetSeconds }),
|
|
758
976
|
...(armedFile === undefined ? {} : { armedFile }),
|
|
759
977
|
...(accessFile === undefined ? {} : { accessFile }),
|
|
@@ -916,9 +1134,17 @@ export function paneOwnership(input: { paneId: string; agentName: string; agents
|
|
|
916
1134
|
if (mine?.name !== undefined) {
|
|
917
1135
|
// A registered agent, just not this fleet's. herdr can name several omp
|
|
918
1136
|
// agents in one directory, and requiring merely *a* name would arm each one.
|
|
1137
|
+
//
|
|
1138
|
+
// The rename hint is conditional because this branch has two very different
|
|
1139
|
+
// causes: a scratch pane that is correctly named something else, and the
|
|
1140
|
+
// fleet's own pane still carrying the name it had before its tick config was
|
|
1141
|
+
// restamped for a project. Only the operator knows which.
|
|
919
1142
|
return {
|
|
920
1143
|
kind: "declined",
|
|
921
|
-
reason:
|
|
1144
|
+
reason:
|
|
1145
|
+
`this pane is agent "${mine.name}", not the fleet agent "${input.agentName}" — ` +
|
|
1146
|
+
`if this is the fleet pane under an old name, \`herdr agent rename ${input.paneId} ${input.agentName}\`; ` +
|
|
1147
|
+
`this session will not tick`,
|
|
922
1148
|
};
|
|
923
1149
|
}
|
|
924
1150
|
|
|
@@ -933,12 +1159,19 @@ export function paneOwnership(input: { paneId: string; agentName: string; agents
|
|
|
933
1159
|
// No name here and nobody else holding it: an ad-hoc pane in the fleet's
|
|
934
1160
|
// directory, which is exactly the session that must stay inert. Fail-closed,
|
|
935
1161
|
// and the fix is named — an orchestrator that lost its registration is one
|
|
936
|
-
// `herdr agent
|
|
1162
|
+
// `herdr agent rename` from ticking again.
|
|
1163
|
+
//
|
|
1164
|
+
// Deliberately NOT `herdr agent start`: that submits omp *into* the pane's
|
|
1165
|
+
// existing shell and requires a pane at a shell prompt hosting no agent
|
|
1166
|
+
// (herdr/README.md). This pane is running the omp session reading this line, so
|
|
1167
|
+
// `agent start` would either be refused or start a second orchestrator in it.
|
|
1168
|
+
// `rename` names the agent herdr already detects, and touches no process.
|
|
937
1169
|
return {
|
|
938
1170
|
kind: "declined",
|
|
939
1171
|
reason:
|
|
940
1172
|
`this pane is not a registered herdr agent, and no pane is running the fleet agent ` +
|
|
941
|
-
`"${input.agentName}" —
|
|
1173
|
+
`"${input.agentName}" — name its running agent with ` +
|
|
1174
|
+
`\`herdr agent rename ${input.paneId} ${input.agentName}\`; ` +
|
|
942
1175
|
`this session will not tick`,
|
|
943
1176
|
};
|
|
944
1177
|
}
|
|
@@ -1265,23 +1498,285 @@ function clearStallMarker(pi: TickApi, cwd: string): void {
|
|
|
1265
1498
|
}
|
|
1266
1499
|
}
|
|
1267
1500
|
|
|
1501
|
+
/**
|
|
1502
|
+
* Best-effort poke that asks a live orchestrator heartbeat to fire soon.
|
|
1503
|
+
*
|
|
1504
|
+
* Same file shape as herdr `recover.sh`: one line `${iso} ${reason}`. Writers
|
|
1505
|
+
* (recover, the dispatch daemon on a false→true condition) never clear it —
|
|
1506
|
+
* only a successful tick send does, so a disarmed or channel-down fleet keeps
|
|
1507
|
+
* the request until gates pass.
|
|
1508
|
+
*/
|
|
1509
|
+
export function requestImmediateTick(cwd: string, reason: string): boolean {
|
|
1510
|
+
const path = join(cwd, TICK_REQUESTED_FILE);
|
|
1511
|
+
const label = reason.trim().length > 0 ? reason.trim() : "wake";
|
|
1512
|
+
try {
|
|
1513
|
+
writeFileSync(path, `${new Date().toISOString()} ${label}\n`);
|
|
1514
|
+
return true;
|
|
1515
|
+
} catch {
|
|
1516
|
+
return false;
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
/** First non-empty token after the optional ISO stamp on a poke line. */
|
|
1521
|
+
export function readTickRequestReason(cwd: string): string | undefined {
|
|
1522
|
+
const path = join(cwd, TICK_REQUESTED_FILE);
|
|
1523
|
+
if (!existsSync(path)) return undefined;
|
|
1524
|
+
let raw: string;
|
|
1525
|
+
try {
|
|
1526
|
+
raw = readFileSync(path, "utf8");
|
|
1527
|
+
} catch {
|
|
1528
|
+
return undefined;
|
|
1529
|
+
}
|
|
1530
|
+
const line = raw.split(/\r?\n/, 1)[0]?.trim() ?? "";
|
|
1531
|
+
if (line.length === 0) return undefined;
|
|
1532
|
+
const parts = line.split(/\s+/);
|
|
1533
|
+
if (parts.length === 0) return undefined;
|
|
1534
|
+
// recover.sh writes `${iso} recover`; a bare reason is also accepted.
|
|
1535
|
+
if (parts.length >= 2 && Number.isFinite(Date.parse(parts[0]!))) {
|
|
1536
|
+
return parts.slice(1).join(" ");
|
|
1537
|
+
}
|
|
1538
|
+
return parts.join(" ");
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1268
1541
|
/**
|
|
1269
1542
|
* Best-effort, same posture as {@link clearStallMarker}. Leaving the file on a
|
|
1270
1543
|
* failed unlink means the next successful send retries the clear; that is
|
|
1271
|
-
* preferable to treating a recover poke as fire-and-forget when the
|
|
1272
|
-
* land.
|
|
1544
|
+
* preferable to treating a recover/condition poke as fire-and-forget when the
|
|
1545
|
+
* tick did land.
|
|
1273
1546
|
*/
|
|
1274
1547
|
function clearTickRequest(pi: TickApi, cwd: string): void {
|
|
1275
1548
|
const path = join(cwd, TICK_REQUESTED_FILE);
|
|
1276
1549
|
if (!existsSync(path)) return;
|
|
1277
1550
|
try {
|
|
1278
1551
|
rmSync(path, { force: true });
|
|
1279
|
-
pi.logger.info("[omp-conductor]
|
|
1552
|
+
pi.logger.info("[omp-conductor] tick request cleared: a tick was sent");
|
|
1280
1553
|
} catch (err) {
|
|
1281
1554
|
pi.logger.error(`[omp-conductor] could not remove ${path}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1282
1555
|
}
|
|
1283
1556
|
}
|
|
1284
1557
|
|
|
1558
|
+
interface PendingLocalTick {
|
|
1559
|
+
id: string;
|
|
1560
|
+
projectName?: string;
|
|
1561
|
+
policy?: ReportingPolicy;
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
interface ActiveLocalTick extends PendingLocalTick {
|
|
1565
|
+
/** A person who writes during an autonomous run is awake by construction. */
|
|
1566
|
+
humanWaiting: boolean;
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
interface TelegramInterrupt {
|
|
1570
|
+
kind: "question" | "message";
|
|
1571
|
+
category: HeldNotice["category"];
|
|
1572
|
+
summary: string;
|
|
1573
|
+
detail: string;
|
|
1574
|
+
hasFiles: boolean;
|
|
1575
|
+
fingerprint: string;
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
function objectRecord(value: unknown): value is Record<string, unknown> {
|
|
1579
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1580
|
+
}
|
|
1581
|
+
function canonicalTelegramPayload(value: unknown): unknown {
|
|
1582
|
+
if (Array.isArray(value)) return value.map(canonicalTelegramPayload);
|
|
1583
|
+
if (!objectRecord(value)) return value;
|
|
1584
|
+
return Object.fromEntries(
|
|
1585
|
+
Object.keys(value)
|
|
1586
|
+
.sort()
|
|
1587
|
+
.map((key) => [key, canonicalTelegramPayload(value[key])]),
|
|
1588
|
+
);
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
|
|
1592
|
+
function serialiseTelegramPayload(value: unknown): string {
|
|
1593
|
+
try {
|
|
1594
|
+
return JSON.stringify(canonicalTelegramPayload(value)) ?? String(value);
|
|
1595
|
+
} catch {
|
|
1596
|
+
return String(value);
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
function telegramInterruptFromTool(
|
|
1601
|
+
toolName: string,
|
|
1602
|
+
input: Record<string, unknown>,
|
|
1603
|
+
): TelegramInterrupt | undefined {
|
|
1604
|
+
let kind: TelegramInterrupt["kind"];
|
|
1605
|
+
let payload: Record<string, unknown> = input;
|
|
1606
|
+
|
|
1607
|
+
if (toolName === TELEGRAM_APPROVAL_TOOL) {
|
|
1608
|
+
kind = "question";
|
|
1609
|
+
} else if (toolName === "telegram_send" || toolName === "telegram_react") {
|
|
1610
|
+
kind = "message";
|
|
1611
|
+
} else if (toolName === "write") {
|
|
1612
|
+
const path = input["path"];
|
|
1613
|
+
if (
|
|
1614
|
+
path !== "xd://telegram_ask" &&
|
|
1615
|
+
path !== "xd://telegram_send" &&
|
|
1616
|
+
path !== "xd://telegram_react"
|
|
1617
|
+
) {
|
|
1618
|
+
return undefined;
|
|
1619
|
+
}
|
|
1620
|
+
kind = path === "xd://telegram_ask" ? "question" : "message";
|
|
1621
|
+
const content = input["content"];
|
|
1622
|
+
if (typeof content === "string") {
|
|
1623
|
+
try {
|
|
1624
|
+
const parsed: unknown = JSON.parse(content);
|
|
1625
|
+
payload = objectRecord(parsed) ? parsed : { content };
|
|
1626
|
+
} catch {
|
|
1627
|
+
payload = { content };
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
} else {
|
|
1631
|
+
return undefined;
|
|
1632
|
+
}
|
|
1633
|
+
if (
|
|
1634
|
+
kind === "message" &&
|
|
1635
|
+
typeof payload["text"] === "string" &&
|
|
1636
|
+
/^\s*QUESTION:\s/i.test(payload["text"])
|
|
1637
|
+
) {
|
|
1638
|
+
// The package floor uses this explicit fallback when telegram_ask is not
|
|
1639
|
+
// mounted. Without the marker a plain send remains a material update.
|
|
1640
|
+
kind = "question";
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
let detail = "";
|
|
1644
|
+
if (kind === "question" && Array.isArray(payload["questions"])) {
|
|
1645
|
+
detail = payload["questions"]
|
|
1646
|
+
.map((value, index) => {
|
|
1647
|
+
if (!objectRecord(value)) return `Question ${index + 1}: ${serialiseTelegramPayload(value)}`;
|
|
1648
|
+
const question = typeof value["question"] === "string" ? value["question"] : "Question";
|
|
1649
|
+
const options = Array.isArray(value["options"])
|
|
1650
|
+
? value["options"]
|
|
1651
|
+
.map((option) =>
|
|
1652
|
+
objectRecord(option) && typeof option["label"] === "string"
|
|
1653
|
+
? `${option["label"]}${
|
|
1654
|
+
typeof option["description"] === "string" && option["description"] !== ""
|
|
1655
|
+
? ` — ${option["description"]}`
|
|
1656
|
+
: ""
|
|
1657
|
+
}`
|
|
1658
|
+
: serialiseTelegramPayload(option),
|
|
1659
|
+
)
|
|
1660
|
+
.join("; ")
|
|
1661
|
+
: "";
|
|
1662
|
+
return options === "" ? question : `${question}\nOptions: ${options}`;
|
|
1663
|
+
})
|
|
1664
|
+
.join("\n\n");
|
|
1665
|
+
} else if (typeof payload["text"] === "string") {
|
|
1666
|
+
detail = payload["text"];
|
|
1667
|
+
}
|
|
1668
|
+
if (detail.trim() === "") detail = serialiseTelegramPayload(payload);
|
|
1669
|
+
|
|
1670
|
+
const flat = detail.replace(/\s+/g, " ").trim();
|
|
1671
|
+
const fallback = kind === "question" ? "Operator question" : "Telegram message";
|
|
1672
|
+
const summary = flat === "" ? fallback : flat.length <= 180 ? flat : `${flat.slice(0, 179)}…`;
|
|
1673
|
+
const canonical = serialiseTelegramPayload({ kind, payload });
|
|
1674
|
+
return {
|
|
1675
|
+
kind,
|
|
1676
|
+
category: kind === "question" ? "decision-needed" : "material",
|
|
1677
|
+
summary,
|
|
1678
|
+
hasFiles: Array.isArray(payload["files"]) && payload["files"].length > 0,
|
|
1679
|
+
detail,
|
|
1680
|
+
fingerprint: createHash("sha256").update(canonical).digest("hex"),
|
|
1681
|
+
};
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
type TelegramInterruptBlock = { block: true; reason: string };
|
|
1685
|
+
|
|
1686
|
+
/**
|
|
1687
|
+
* A locally injected tick is an autonomous actor. Its direct Telegram calls
|
|
1688
|
+
* therefore pass through the same category and availability decision as daemon
|
|
1689
|
+
* escalations. Human-originated runs, including a message steered into a tick,
|
|
1690
|
+
* are exempt: the operator is already present and waiting for the reply.
|
|
1691
|
+
*/
|
|
1692
|
+
function autonomousTelegramInterruptBlock(
|
|
1693
|
+
pi: TickApi,
|
|
1694
|
+
session: TickSession,
|
|
1695
|
+
event: { toolName: string; input: Record<string, unknown> },
|
|
1696
|
+
): TelegramInterruptBlock | undefined {
|
|
1697
|
+
const active = session.activeLocalTick;
|
|
1698
|
+
if (active === undefined || active.humanWaiting) return undefined;
|
|
1699
|
+
const interrupt = telegramInterruptFromTool(event.toolName, event.input);
|
|
1700
|
+
if (interrupt === undefined) return undefined;
|
|
1701
|
+
if (active.projectName === undefined) {
|
|
1702
|
+
return {
|
|
1703
|
+
block: true,
|
|
1704
|
+
reason:
|
|
1705
|
+
"Operator availability policy is unavailable for this autonomous tick, so direct Telegram is blocked fail-closed. " +
|
|
1706
|
+
"Repair the conductor config; do not retry through another Telegram path.",
|
|
1707
|
+
};
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
let policy = active.policy;
|
|
1711
|
+
let configReadable = true;
|
|
1712
|
+
try {
|
|
1713
|
+
policy = findProject(loadConfig(), active.projectName).reporting;
|
|
1714
|
+
} catch (err) {
|
|
1715
|
+
configReadable = false;
|
|
1716
|
+
pi.logger.error(
|
|
1717
|
+
`[omp-conductor] operator availability config unreadable during Telegram call; blocking fail-closed: ${
|
|
1718
|
+
err instanceof Error ? err.message : String(err)
|
|
1719
|
+
}`,
|
|
1720
|
+
);
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
const at = Date.now();
|
|
1724
|
+
const disposition = configReadable
|
|
1725
|
+
? interruptDisposition(policy, interrupt.category, at)
|
|
1726
|
+
: "availability";
|
|
1727
|
+
if (disposition === "interrupt") return undefined;
|
|
1728
|
+
const release =
|
|
1729
|
+
disposition === "availability"
|
|
1730
|
+
? "the availability window opens or the daily digest runs"
|
|
1731
|
+
: "the daily digest runs";
|
|
1732
|
+
if (interrupt.hasFiles) {
|
|
1733
|
+
return {
|
|
1734
|
+
block: true,
|
|
1735
|
+
reason:
|
|
1736
|
+
`Operator policy forbids this autonomous Telegram ${interrupt.kind} until ${release}, ` +
|
|
1737
|
+
"and attachment-bearing Telegram sends cannot be durably replayed. Nothing was sent or held. " +
|
|
1738
|
+
"Do not retry through another Telegram path while the policy forbids delivery.",
|
|
1739
|
+
};
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
const key = `${active.projectName}:autonomous-telegram:${active.id}:${interrupt.fingerprint}`;
|
|
1743
|
+
try {
|
|
1744
|
+
const store = openStore(dbPath());
|
|
1745
|
+
try {
|
|
1746
|
+
if (!store.wasNotified(key)) {
|
|
1747
|
+
store.addHeldNotice({
|
|
1748
|
+
id: createHash("sha256").update(`held-notice\0${key}`).digest("hex"),
|
|
1749
|
+
project: active.projectName,
|
|
1750
|
+
category: interrupt.category,
|
|
1751
|
+
summary: interrupt.summary,
|
|
1752
|
+
detail: interrupt.detail,
|
|
1753
|
+
createdAt: at,
|
|
1754
|
+
...(disposition === "availability" ? { releaseOnAvailable: true } : {}),
|
|
1755
|
+
});
|
|
1756
|
+
store.markNotified(key);
|
|
1757
|
+
}
|
|
1758
|
+
} finally {
|
|
1759
|
+
store.close();
|
|
1760
|
+
}
|
|
1761
|
+
} catch (err) {
|
|
1762
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
1763
|
+
pi.logger.error(`[omp-conductor] could not persist held Telegram ${interrupt.kind}: ${reason}`);
|
|
1764
|
+
return {
|
|
1765
|
+
block: true,
|
|
1766
|
+
reason:
|
|
1767
|
+
`Operator policy forbids this autonomous Telegram ${interrupt.kind}, and the durable hold failed: ${reason}. ` +
|
|
1768
|
+
"Do not retry through another Telegram path.",
|
|
1769
|
+
};
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
return {
|
|
1773
|
+
block: true,
|
|
1774
|
+
reason:
|
|
1775
|
+
`Operator policy durably held this autonomous Telegram ${interrupt.kind} until ${release}. ` +
|
|
1776
|
+
"Do not retry through another Telegram path.",
|
|
1777
|
+
};
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1285
1780
|
/** Everything one tick remembers for the next. */
|
|
1286
1781
|
interface TickSession {
|
|
1287
1782
|
/**
|
|
@@ -1310,6 +1805,17 @@ interface TickSession {
|
|
|
1310
1805
|
bridgeTokenAtStart: boolean;
|
|
1311
1806
|
/** Consecutive {@link PENDING_REASON} skips — see {@link STALL_MARKER_FILE}. */
|
|
1312
1807
|
pendingSkips: number;
|
|
1808
|
+
/** Ticks sent as follow-ups but not yet observed by the agent loop. */
|
|
1809
|
+
pendingLocalTicks: PendingLocalTick[];
|
|
1810
|
+
/**
|
|
1811
|
+
* Whether the stranded shared arm marker has been named. Latched for
|
|
1812
|
+
* {@link TickSession.scopeFallbackLogged}'s reason: a host that gained a
|
|
1813
|
+
* second project without re-running setup would otherwise repeat the same
|
|
1814
|
+
* line every interval, forever.
|
|
1815
|
+
*/
|
|
1816
|
+
legacyArmLogged: boolean;
|
|
1817
|
+
/** The local tick whose agent loop is currently running, if any. */
|
|
1818
|
+
activeLocalTick?: ActiveLocalTick;
|
|
1313
1819
|
}
|
|
1314
1820
|
|
|
1315
1821
|
/**
|
|
@@ -1318,8 +1824,15 @@ interface TickSession {
|
|
|
1318
1824
|
* otherwise emit a notification every interval, forever.
|
|
1319
1825
|
*/
|
|
1320
1826
|
function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSession): void {
|
|
1827
|
+
const arm = config.armedFile === undefined ? undefined : resolveArmState(config.armedFile, config.project);
|
|
1828
|
+
if (arm?.legacy === "stranded" && !session.legacyArmLogged) {
|
|
1829
|
+
session.legacyArmLogged = true;
|
|
1830
|
+
pi.logger.error(
|
|
1831
|
+
`[omp-conductor] ${legacyArmedMarkerPath()}: ${LEGACY_ARM_MARKER_DETAIL} — this heartbeat stays disarmed`,
|
|
1832
|
+
);
|
|
1833
|
+
}
|
|
1321
1834
|
const decision = tickDecision({
|
|
1322
|
-
armed:
|
|
1835
|
+
armed: arm === undefined || arm.armed,
|
|
1323
1836
|
channelOk: config.accessFile === undefined || channelIsUp(config.accessFile),
|
|
1324
1837
|
hasPending: ctx.hasPendingMessages(),
|
|
1325
1838
|
});
|
|
@@ -1346,9 +1859,9 @@ function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSe
|
|
|
1346
1859
|
|
|
1347
1860
|
// Floor refresh is independent of which prompt we send: a custom message still
|
|
1348
1861
|
// expects ORCHESTRATOR.md / AGENTS.md to track the installed package.
|
|
1349
|
-
refreshComposedBriefBestEffort();
|
|
1862
|
+
refreshComposedBriefBestEffort(config.project);
|
|
1350
1863
|
|
|
1351
|
-
const scope = resolveTickScope();
|
|
1864
|
+
const scope = resolveTickScope(config.project);
|
|
1352
1865
|
// The transport contract, read once from the same file at the same moment so
|
|
1353
1866
|
// the approval line, the delivery rule and the narration line cannot disagree
|
|
1354
1867
|
// (#169, #179). No access file means no fleet channel to judge; a session
|
|
@@ -1393,22 +1906,68 @@ function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSe
|
|
|
1393
1906
|
try {
|
|
1394
1907
|
const lastKey = store.lastDigestDedupeKey(scope.projectName);
|
|
1395
1908
|
const lastDay = lastKey === undefined ? undefined : lastKey.slice("digest:".length);
|
|
1909
|
+
const due = digestDue(policy ?? DEFAULT_REPORT_POLICY, lastDay, at);
|
|
1910
|
+
// Reserve availability-held rows before reading them. The same SQLite
|
|
1911
|
+
// transaction releases pending catch-ups; daemon enqueue attempts then
|
|
1912
|
+
// observe the lease, closing both orders of the snapshot-to-handoff race.
|
|
1913
|
+
const availabilityReserved =
|
|
1914
|
+
due &&
|
|
1915
|
+
store.reserveAvailabilityDigest(
|
|
1916
|
+
scope.projectName,
|
|
1917
|
+
localDayKey(at, digestPolicy.timezone ?? policy?.availability?.timezone),
|
|
1918
|
+
at,
|
|
1919
|
+
at +
|
|
1920
|
+
Math.max(
|
|
1921
|
+
MIN_AVAILABILITY_DIGEST_LEASE_MS,
|
|
1922
|
+
(config.budgetSeconds ?? DEFAULT_TICK_BUDGET_SECONDS) * 1_000 + 5 * 60_000,
|
|
1923
|
+
),
|
|
1924
|
+
);
|
|
1925
|
+
const backlog = store.digestBacklog(scope.projectName);
|
|
1926
|
+
const visibleBacklog: DigestBacklog = availabilityReserved
|
|
1927
|
+
? backlog
|
|
1928
|
+
: {
|
|
1929
|
+
...backlog,
|
|
1930
|
+
heldNoticeCount: Math.max(
|
|
1931
|
+
0,
|
|
1932
|
+
backlog.heldNoticeCount - (backlog.availabilityHeldNoticeCount ?? 0),
|
|
1933
|
+
),
|
|
1934
|
+
};
|
|
1396
1935
|
reportingConstraint = tickReportingConstraint(
|
|
1397
1936
|
policy,
|
|
1398
1937
|
{
|
|
1399
|
-
due
|
|
1938
|
+
due,
|
|
1400
1939
|
cadence,
|
|
1401
1940
|
at: digestPolicy.at,
|
|
1402
1941
|
timezone: digestPolicy.timezone,
|
|
1403
1942
|
},
|
|
1404
|
-
|
|
1943
|
+
visibleBacklog.heldNoticeCount,
|
|
1405
1944
|
);
|
|
1945
|
+
if (
|
|
1946
|
+
due &&
|
|
1947
|
+
(visibleBacklog.materialCount > 0 || visibleBacklog.heldNoticeCount > 0)
|
|
1948
|
+
) {
|
|
1949
|
+
const notices = store
|
|
1950
|
+
.undigestedNotices(scope.projectName)
|
|
1951
|
+
.filter((notice) => notice.releaseOnAvailable !== true || availabilityReserved);
|
|
1952
|
+
reportingConstraint +=
|
|
1953
|
+
`\n${formatDigestLedger(
|
|
1954
|
+
store.undigestedMaterialEvents(scope.projectName),
|
|
1955
|
+
notices,
|
|
1956
|
+
visibleBacklog,
|
|
1957
|
+
)}`;
|
|
1958
|
+
}
|
|
1406
1959
|
} finally {
|
|
1407
1960
|
store.close();
|
|
1408
1961
|
}
|
|
1409
1962
|
}
|
|
1410
1963
|
content = `${defaultTickMessage(new Date(), scope.briefPath, scope.policyPath)}\n${reportingConstraint}\n${bridged ? TICK_DELIVERY_RULE_BRIDGED : TICK_DELIVERY_RULE}`;
|
|
1411
1964
|
}
|
|
1965
|
+
if (scope.projectName !== undefined) {
|
|
1966
|
+
// Unlike ordinary reporting prose, this is a mechanical clock reading and
|
|
1967
|
+
// survives a custom heartbeat message. The model never infers whether the
|
|
1968
|
+
// operator can be interrupted.
|
|
1969
|
+
content = `${content}\n${availabilityPrompt(scope.policy, Date.now())}`;
|
|
1970
|
+
}
|
|
1412
1971
|
let frictionStore: Store | undefined;
|
|
1413
1972
|
let frictionSignals: FrictionSignal[] = [];
|
|
1414
1973
|
const now = Date.now();
|
|
@@ -1442,7 +2001,10 @@ function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSe
|
|
|
1442
2001
|
// tracker (#181). A failed config read skips the line rather than
|
|
1443
2002
|
// wedging the tick.
|
|
1444
2003
|
try {
|
|
1445
|
-
|
|
2004
|
+
// Named: this block is already inside `scope.projectName !== undefined`,
|
|
2005
|
+
// so the resolved name is in hand and an un-named lookup would refuse
|
|
2006
|
+
// to guess on a host with a second project.
|
|
2007
|
+
const project = findProject(loadConfig(), scope.projectName);
|
|
1446
2008
|
const queue = queueDigestLine(
|
|
1447
2009
|
frictionStore.latestDispatch(scope.projectName),
|
|
1448
2010
|
project.queueLabel,
|
|
@@ -1526,11 +2088,28 @@ function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSe
|
|
|
1526
2088
|
// No `accessFile` means no fleet bridge to judge, exactly as above.
|
|
1527
2089
|
if (profile?.kind === "interactive") content = `${content}\n${TICK_NARRATION_RULE}`;
|
|
1528
2090
|
|
|
2091
|
+
// Recover / condition-met pokes are auditable: the same reason the writer put
|
|
2092
|
+
// on the sentinel line lands in the prompt and the "tick sent" log.
|
|
2093
|
+
const wakeReason = readTickRequestReason(ctx.cwd);
|
|
2094
|
+
if (wakeReason !== undefined) content = `${content}\nWake reason: ${wakeReason}`;
|
|
2095
|
+
|
|
2096
|
+
const pendingLocalTick: PendingLocalTick = {
|
|
2097
|
+
id: randomUUID(),
|
|
2098
|
+
...(scope.projectName === undefined ? {} : { projectName: scope.projectName }),
|
|
2099
|
+
...(scope.policy === undefined ? {} : { policy: scope.policy }),
|
|
2100
|
+
};
|
|
2101
|
+
session.pendingLocalTicks.push(pendingLocalTick);
|
|
1529
2102
|
try {
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
2103
|
+
try {
|
|
2104
|
+
pi.sendMessage(
|
|
2105
|
+
{ customType: TICK_CUSTOM_TYPE, content, display: true, attribution: "user" },
|
|
2106
|
+
{ triggerTurn: true, deliverAs: "followUp" },
|
|
2107
|
+
);
|
|
2108
|
+
} catch (err) {
|
|
2109
|
+
const pendingIndex = session.pendingLocalTicks.indexOf(pendingLocalTick);
|
|
2110
|
+
if (pendingIndex >= 0) session.pendingLocalTicks.splice(pendingIndex, 1);
|
|
2111
|
+
throw err;
|
|
2112
|
+
}
|
|
1534
2113
|
if (scope.projectName !== undefined && frictionSignals.length > 0) {
|
|
1535
2114
|
try {
|
|
1536
2115
|
frictionStore?.markFrictionSurfaced(
|
|
@@ -1547,13 +2126,19 @@ function tick(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSe
|
|
|
1547
2126
|
} finally {
|
|
1548
2127
|
frictionStore?.close();
|
|
1549
2128
|
}
|
|
1550
|
-
pi.logger.info(
|
|
2129
|
+
pi.logger.info(
|
|
2130
|
+
wakeReason === undefined
|
|
2131
|
+
? `[omp-conductor] tick sent: ${decision.reason}`
|
|
2132
|
+
: `[omp-conductor] tick sent: ${decision.reason} (wake: ${wakeReason})`,
|
|
2133
|
+
{ reason: decision.reason, ...(wakeReason === undefined ? {} : { wakeReason }) },
|
|
2134
|
+
);
|
|
1551
2135
|
// An empty queue at send time is the proof the previous tick was consumed, so
|
|
1552
2136
|
// this is the only place either the counter or the marker is cleared.
|
|
1553
2137
|
session.pendingSkips = 0;
|
|
1554
2138
|
clearStallMarker(pi, ctx.cwd);
|
|
1555
|
-
//
|
|
2139
|
+
// Immediate-tick poke is consumed only on a real send — gates still apply above.
|
|
1556
2140
|
clearTickRequest(pi, ctx.cwd);
|
|
2141
|
+
|
|
1557
2142
|
}
|
|
1558
2143
|
|
|
1559
2144
|
function writeTickRuntimeStatus(pi: TickApi, cwd: string, intervalSeconds: number): void {
|
|
@@ -1574,22 +2159,40 @@ function writeTickRuntimeStatus(pi: TickApi, cwd: string, intervalSeconds: numbe
|
|
|
1574
2159
|
}
|
|
1575
2160
|
|
|
1576
2161
|
/**
|
|
1577
|
-
* Arm the interval heartbeat,
|
|
2162
|
+
* Arm the interval heartbeat, honour a poke already waiting at arm time, and
|
|
2163
|
+
* poll mid-interval for later pokes (recover resume, condition-met #329).
|
|
1578
2164
|
* Extracted so the ownership-retry path and the immediate-accept path cannot
|
|
1579
|
-
* drift: both must fire the same "do not wait a full interval after
|
|
1580
|
-
* behaviour.
|
|
2165
|
+
* drift: both must fire the same "do not wait a full interval after a poke"
|
|
2166
|
+
* behaviour. Every path still runs {@link tick} → {@link tickDecision}, so a
|
|
2167
|
+
* live turn coalesces rather than stacking concurrent ticks.
|
|
1581
2168
|
*/
|
|
1582
2169
|
function armTickHeartbeat(pi: TickApi, ctx: TickContext, config: TickConfig, session: TickSession): void {
|
|
1583
2170
|
writeTickRuntimeStatus(pi, ctx.cwd, config.intervalSeconds);
|
|
1584
|
-
|
|
2171
|
+
const runScheduledTick = (): void => {
|
|
1585
2172
|
try {
|
|
1586
2173
|
tick(pi, ctx, config, session);
|
|
1587
2174
|
} finally {
|
|
1588
2175
|
writeTickRuntimeStatus(pi, ctx.cwd, config.intervalSeconds);
|
|
1589
2176
|
}
|
|
1590
|
-
}
|
|
2177
|
+
};
|
|
2178
|
+
ctx.setInterval(runScheduledTick, config.intervalSeconds * 1000);
|
|
2179
|
+
// Short mid-interval poll so a condition that flips between heartbeats does
|
|
2180
|
+
// not wait a full cycle. Cap at the heartbeat itself so a misconfigured short
|
|
2181
|
+
// interval cannot arm a second timer faster than the primary.
|
|
2182
|
+
const pollMs = Math.min(TICK_REQUEST_POLL_MS, config.intervalSeconds * 1000);
|
|
2183
|
+
if (pollMs < config.intervalSeconds * 1000) {
|
|
2184
|
+
ctx.setInterval(() => {
|
|
2185
|
+
if (!existsSync(join(ctx.cwd, TICK_REQUESTED_FILE))) return;
|
|
2186
|
+
const reason = readTickRequestReason(ctx.cwd) ?? "wake";
|
|
2187
|
+
pi.logger.info(
|
|
2188
|
+
`[omp-conductor] tick requested by ${reason} — firing without waiting for the interval`,
|
|
2189
|
+
);
|
|
2190
|
+
runScheduledTick();
|
|
2191
|
+
}, pollMs);
|
|
2192
|
+
}
|
|
1591
2193
|
if (!existsSync(join(ctx.cwd, TICK_REQUESTED_FILE))) return;
|
|
1592
|
-
|
|
2194
|
+
const reason = readTickRequestReason(ctx.cwd) ?? "wake";
|
|
2195
|
+
pi.logger.info(`[omp-conductor] tick requested by ${reason} — firing without waiting for the interval`);
|
|
1593
2196
|
tick(pi, ctx, config, session);
|
|
1594
2197
|
}
|
|
1595
2198
|
|
|
@@ -1645,14 +2248,24 @@ export default function orchestratorTickExtension(pi: TickApi): void {
|
|
|
1645
2248
|
approvalToolMissingLogged: false,
|
|
1646
2249
|
bridgeTokenAtStart: true,
|
|
1647
2250
|
pendingSkips: 0,
|
|
2251
|
+
pendingLocalTicks: [],
|
|
2252
|
+
legacyArmLogged: false,
|
|
1648
2253
|
};
|
|
1649
2254
|
let releaseGateArmed = false;
|
|
2255
|
+
let availabilityGateArmed = false;
|
|
1650
2256
|
let guardArmed = false;
|
|
1651
2257
|
// An activation file makes this a fleet directory before Herdr can prove
|
|
1652
2258
|
// which pane owns it. The gate therefore starts closed and only honours a
|
|
1653
2259
|
// configured grant after ownership is accepted.
|
|
1654
2260
|
let releaseAuthorityAccepted = false;
|
|
1655
|
-
|
|
2261
|
+
/**
|
|
2262
|
+
* `configuredProject` is {@link TickConfig.project}, and it is what stops a
|
|
2263
|
+
* second configured project from collapsing every release grant this fleet
|
|
2264
|
+
* really holds to {@link DENIED_RELEASE_GRANTS}. Undefined for a tick config
|
|
2265
|
+
* that names none — including an invalid one, which arms this gate before the
|
|
2266
|
+
* config can be trusted at all.
|
|
2267
|
+
*/
|
|
2268
|
+
const armReleaseGate = (configuredProject?: string): void => {
|
|
1656
2269
|
if (releaseGateArmed) return;
|
|
1657
2270
|
releaseGateArmed = true;
|
|
1658
2271
|
(pi as TickApi & {
|
|
@@ -1672,7 +2285,7 @@ export default function orchestratorTickExtension(pi: TickApi): void {
|
|
|
1672
2285
|
let grants: ResolvedGrants = DENIED_RELEASE_GRANTS;
|
|
1673
2286
|
let external = true;
|
|
1674
2287
|
try {
|
|
1675
|
-
const project = findProject(loadConfig());
|
|
2288
|
+
const project = findProject(loadConfig(), configuredProject);
|
|
1676
2289
|
projectName = project.name;
|
|
1677
2290
|
grants = resolveReleaseGrants(project);
|
|
1678
2291
|
external = project.escalation.orchestrator === "external";
|
|
@@ -1716,6 +2329,64 @@ export default function orchestratorTickExtension(pi: TickApi): void {
|
|
|
1716
2329
|
});
|
|
1717
2330
|
};
|
|
1718
2331
|
|
|
2332
|
+
/** `configuredProject` carries {@link TickConfig.project} for the same reason
|
|
2333
|
+
* {@link armReleaseGate} takes it: a recovered tick must reconstruct *this*
|
|
2334
|
+
* fleet's availability policy, not refuse to guess between two projects. */
|
|
2335
|
+
const armAvailabilityGate = (configuredProject?: string): void => {
|
|
2336
|
+
if (availabilityGateArmed) return;
|
|
2337
|
+
availabilityGateArmed = true;
|
|
2338
|
+
|
|
2339
|
+
// Custom messages sent with `pi.sendMessage` bypass `before_agent_start`.
|
|
2340
|
+
// `message_start` is the first lifecycle event that carries their exact
|
|
2341
|
+
// custom type, so it is the reliable boundary between a local tick and an
|
|
2342
|
+
// inbound human turn.
|
|
2343
|
+
pi.on("message_start", (event) => {
|
|
2344
|
+
const message = event.message;
|
|
2345
|
+
if (message.role === "custom" && message.customType === TICK_CUSTOM_TYPE) {
|
|
2346
|
+
const pending = session.pendingLocalTicks.shift();
|
|
2347
|
+
if (pending !== undefined) {
|
|
2348
|
+
session.activeLocalTick = { ...pending, humanWaiting: false };
|
|
2349
|
+
} else {
|
|
2350
|
+
// A queued custom tick can survive a host restart after the in-memory
|
|
2351
|
+
// enqueue record does not. Reconstruct its scope so recovery cannot
|
|
2352
|
+
// silently turn an autonomous run into an interactive one.
|
|
2353
|
+
const scope = resolveTickScope(configuredProject);
|
|
2354
|
+
const id =
|
|
2355
|
+
typeof message.timestamp === "number"
|
|
2356
|
+
? createHash("sha256").update(`recovered-tick\0${message.timestamp}`).digest("hex")
|
|
2357
|
+
: randomUUID();
|
|
2358
|
+
session.activeLocalTick = {
|
|
2359
|
+
id,
|
|
2360
|
+
...(scope.projectName === undefined ? {} : { projectName: scope.projectName }),
|
|
2361
|
+
...(scope.policy === undefined ? {} : { policy: scope.policy }),
|
|
2362
|
+
humanWaiting: false,
|
|
2363
|
+
};
|
|
2364
|
+
}
|
|
2365
|
+
return;
|
|
2366
|
+
}
|
|
2367
|
+
if (
|
|
2368
|
+
session.activeLocalTick !== undefined &&
|
|
2369
|
+
message.role === "user" &&
|
|
2370
|
+
message.synthetic !== true &&
|
|
2371
|
+
message.attribution !== "agent"
|
|
2372
|
+
) {
|
|
2373
|
+
session.activeLocalTick.humanWaiting = true;
|
|
2374
|
+
}
|
|
2375
|
+
});
|
|
2376
|
+
pi.on("agent_end", () => {
|
|
2377
|
+
session.activeLocalTick = undefined;
|
|
2378
|
+
});
|
|
2379
|
+
(pi as TickApi & {
|
|
2380
|
+
on(
|
|
2381
|
+
event: "tool_call",
|
|
2382
|
+
handler: (
|
|
2383
|
+
event: { toolName: string; input: Record<string, unknown> },
|
|
2384
|
+
ctx: unknown,
|
|
2385
|
+
) => TelegramInterruptBlock | undefined,
|
|
2386
|
+
): void;
|
|
2387
|
+
}).on("tool_call", (event) => autonomousTelegramInterruptBlock(pi, session, event));
|
|
2388
|
+
};
|
|
2389
|
+
|
|
1719
2390
|
pi.on("session_start", (_event, ctx) => {
|
|
1720
2391
|
if (decided) return;
|
|
1721
2392
|
|
|
@@ -1742,7 +2413,12 @@ export default function orchestratorTickExtension(pi: TickApi): void {
|
|
|
1742
2413
|
|
|
1743
2414
|
// Present but invalid still identifies a fleet directory. Install the
|
|
1744
2415
|
// fail-closed handler before validation or ownership can return early.
|
|
1745
|
-
|
|
2416
|
+
// The project this fleet cwd ticks for, so both gates resolve its own policy
|
|
2417
|
+
// rather than collapsing fail-closed on a host with a second project. An
|
|
2418
|
+
// invalid config names none — it cannot be trusted to.
|
|
2419
|
+
const configuredProject = result.kind === "ok" ? result.config.project : undefined;
|
|
2420
|
+
armReleaseGate(configuredProject);
|
|
2421
|
+
armAvailabilityGate(configuredProject);
|
|
1746
2422
|
|
|
1747
2423
|
if (result.kind === "invalid") {
|
|
1748
2424
|
const detail = `${result.path}: ${result.problem}`;
|