crewly 1.8.4 → 1.8.6
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/config/roles/_common/wiki-instructions.md +33 -0
- package/config/roles/orchestrator/prompt.md +66 -4
- package/config/roles/team-leader/prompt.md +38 -0
- package/config/skills/agent/core/wiki-query/SKILL.md +66 -0
- package/config/skills/agent/core/wiki-query/execute.sh +107 -0
- package/config/skills/orchestrator/wiki-bookkeep/SKILL.md +71 -0
- package/config/skills/orchestrator/wiki-bookkeep/execute.sh +72 -0
- package/config/skills/orchestrator/wiki-ingest/SKILL.md +63 -0
- package/config/skills/orchestrator/wiki-ingest/execute.sh +113 -0
- package/config/skills/orchestrator/wiki-process-queue/SKILL.md +71 -0
- package/config/skills/orchestrator/wiki-process-queue/execute.sh +93 -0
- package/config/skills/orchestrator/wiki-queue-add/SKILL.md +89 -0
- package/config/skills/orchestrator/wiki-queue-add/execute.sh +115 -0
- package/dist/backend/backend/src/controllers/chat/chat.controller.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/chat/chat.controller.js +20 -0
- package/dist/backend/backend/src/controllers/chat/chat.controller.js.map +1 -1
- package/dist/backend/backend/src/controllers/slack/slack.controller.d.ts.map +1 -1
- package/dist/backend/backend/src/controllers/slack/slack.controller.js +15 -0
- package/dist/backend/backend/src/controllers/slack/slack.controller.js.map +1 -1
- package/dist/backend/backend/src/controllers/wiki/wiki.controller.d.ts +134 -0
- package/dist/backend/backend/src/controllers/wiki/wiki.controller.d.ts.map +1 -0
- package/dist/backend/backend/src/controllers/wiki/wiki.controller.js +718 -0
- package/dist/backend/backend/src/controllers/wiki/wiki.controller.js.map +1 -0
- package/dist/backend/backend/src/controllers/wiki/wiki.routes.d.ts +23 -0
- package/dist/backend/backend/src/controllers/wiki/wiki.routes.d.ts.map +1 -0
- package/dist/backend/backend/src/controllers/wiki/wiki.routes.js +43 -0
- package/dist/backend/backend/src/controllers/wiki/wiki.routes.js.map +1 -0
- package/dist/backend/backend/src/index.d.ts.map +1 -1
- package/dist/backend/backend/src/index.js +65 -0
- package/dist/backend/backend/src/index.js.map +1 -1
- package/dist/backend/backend/src/routes/api.routes.d.ts.map +1 -1
- package/dist/backend/backend/src/routes/api.routes.js +4 -0
- package/dist/backend/backend/src/routes/api.routes.js.map +1 -1
- package/dist/backend/backend/src/services/orc/orc-delivery-enforcer.service.d.ts +142 -0
- package/dist/backend/backend/src/services/orc/orc-delivery-enforcer.service.d.ts.map +1 -0
- package/dist/backend/backend/src/services/orc/orc-delivery-enforcer.service.js +265 -0
- package/dist/backend/backend/src/services/orc/orc-delivery-enforcer.service.js.map +1 -0
- package/dist/backend/backend/src/services/session/pty/pty-session.d.ts +28 -0
- package/dist/backend/backend/src/services/session/pty/pty-session.d.ts.map +1 -1
- package/dist/backend/backend/src/services/session/pty/pty-session.js +162 -4
- package/dist/backend/backend/src/services/session/pty/pty-session.js.map +1 -1
- package/dist/backend/backend/src/services/wiki/referenced-by.resolver.d.ts +69 -0
- package/dist/backend/backend/src/services/wiki/referenced-by.resolver.d.ts.map +1 -0
- package/dist/backend/backend/src/services/wiki/referenced-by.resolver.js +174 -0
- package/dist/backend/backend/src/services/wiki/referenced-by.resolver.js.map +1 -0
- package/dist/backend/backend/src/services/wiki/schema-loader.service.d.ts +57 -0
- package/dist/backend/backend/src/services/wiki/schema-loader.service.d.ts.map +1 -0
- package/dist/backend/backend/src/services/wiki/schema-loader.service.js +183 -0
- package/dist/backend/backend/src/services/wiki/schema-loader.service.js.map +1 -0
- package/dist/backend/backend/src/services/wiki/wiki-bookkeep-trigger.service.d.ts +86 -0
- package/dist/backend/backend/src/services/wiki/wiki-bookkeep-trigger.service.d.ts.map +1 -0
- package/dist/backend/backend/src/services/wiki/wiki-bookkeep-trigger.service.js +187 -0
- package/dist/backend/backend/src/services/wiki/wiki-bookkeep-trigger.service.js.map +1 -0
- package/dist/backend/backend/src/services/wiki/wiki-bookkeep.service.d.ts +116 -0
- package/dist/backend/backend/src/services/wiki/wiki-bookkeep.service.d.ts.map +1 -0
- package/dist/backend/backend/src/services/wiki/wiki-bookkeep.service.js +299 -0
- package/dist/backend/backend/src/services/wiki/wiki-bookkeep.service.js.map +1 -0
- package/dist/backend/backend/src/services/wiki/wiki-chat-subscriber.service.d.ts +74 -0
- package/dist/backend/backend/src/services/wiki/wiki-chat-subscriber.service.d.ts.map +1 -0
- package/dist/backend/backend/src/services/wiki/wiki-chat-subscriber.service.js +154 -0
- package/dist/backend/backend/src/services/wiki/wiki-chat-subscriber.service.js.map +1 -0
- package/dist/backend/backend/src/services/wiki/wiki-ingest.service.d.ts +100 -0
- package/dist/backend/backend/src/services/wiki/wiki-ingest.service.d.ts.map +1 -0
- package/dist/backend/backend/src/services/wiki/wiki-ingest.service.js +212 -0
- package/dist/backend/backend/src/services/wiki/wiki-ingest.service.js.map +1 -0
- package/dist/backend/backend/src/services/wiki/wiki-process.service.d.ts +84 -0
- package/dist/backend/backend/src/services/wiki/wiki-process.service.d.ts.map +1 -0
- package/dist/backend/backend/src/services/wiki/wiki-process.service.js +138 -0
- package/dist/backend/backend/src/services/wiki/wiki-process.service.js.map +1 -0
- package/dist/backend/backend/src/services/wiki/wiki-query.service.d.ts +115 -0
- package/dist/backend/backend/src/services/wiki/wiki-query.service.d.ts.map +1 -0
- package/dist/backend/backend/src/services/wiki/wiki-query.service.js +291 -0
- package/dist/backend/backend/src/services/wiki/wiki-query.service.js.map +1 -0
- package/dist/backend/backend/src/services/wiki/wiki-queue.service.d.ts +115 -0
- package/dist/backend/backend/src/services/wiki/wiki-queue.service.d.ts.map +1 -0
- package/dist/backend/backend/src/services/wiki/wiki-queue.service.js +261 -0
- package/dist/backend/backend/src/services/wiki/wiki-queue.service.js.map +1 -0
- package/dist/backend/backend/src/services/wiki/wiki.types.d.ts +84 -0
- package/dist/backend/backend/src/services/wiki/wiki.types.d.ts.map +1 -0
- package/dist/backend/backend/src/services/wiki/wiki.types.js +10 -0
- package/dist/backend/backend/src/services/wiki/wiki.types.js.map +1 -0
- package/frontend/dist/assets/{index-b279da34.js → index-cc115bb4.js} +246 -246
- package/frontend/dist/assets/{index-c07e04c0.css → index-db3f5041.css} +1 -1
- package/frontend/dist/index.html +2 -2
- package/package.json +1 -1
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OrcDeliveryEnforcerService
|
|
3
|
+
*
|
|
4
|
+
* **Why this exists** (2026-05-23 incident, the "affiliate research" case):
|
|
5
|
+
*
|
|
6
|
+
* Steve asked the team to research affiliate programs at 12:59 PM. ORC
|
|
7
|
+
* acknowledged at 1:00 PM with "Atlas + Sage/Kai 派出去了, ETA 30-40min".
|
|
8
|
+
* Sage finished Phase 1 at 1:15, Kai finished Phase 2 at 1:28 — but
|
|
9
|
+
* Steve never saw the result. ORC's terminal logs claimed the pipeline
|
|
10
|
+
* was "closed" and the result "already routed to Steve at Slack thread
|
|
11
|
+
* ...", but there were zero `/api/slack/send` calls all day. ORC's
|
|
12
|
+
* internal mental model believed delivery happened; the actual skill
|
|
13
|
+
* call didn't.
|
|
14
|
+
*
|
|
15
|
+
* Root cause: ORC's prompt has a HARD pre-yield gate ("any `[CHAT:slack-…]`
|
|
16
|
+
* MUST have a `reply-slack` before yielding"). But for a long-running
|
|
17
|
+
* delegation, the user-visible deliverable comes from the *agent* via
|
|
18
|
+
* `[DONE]`-style status reports — long AFTER ORC's initial ack. ORC
|
|
19
|
+
* reads the agent's `[DONE]` as internal team chatter, narrates "pipeline
|
|
20
|
+
* closed", and yields without delivering to Steve. The prompt rule
|
|
21
|
+
* doesn't catch this case because the prompt looks for `[CHAT:slack-…]`
|
|
22
|
+
* (user-originating) not `[DONE]` (agent-originating).
|
|
23
|
+
*
|
|
24
|
+
* **What this service does:**
|
|
25
|
+
*
|
|
26
|
+
* 1. When a worker agent posts `[DONE]` / `[COMPLETED]` to a
|
|
27
|
+
* slack-prefixed conversation (chat.controller's "Agent status
|
|
28
|
+
* routed to orchestrator" path), call {@link markPendingDelivery}.
|
|
29
|
+
* The service records a pending delivery with `dueBy = now + 3 min`.
|
|
30
|
+
*
|
|
31
|
+
* 2. When ORC successfully posts via `/api/slack/send` to the same
|
|
32
|
+
* thread, call {@link markDelivered}. Pending delivery cleared.
|
|
33
|
+
*
|
|
34
|
+
* 3. A timer scans every 30 s. For any pending delivery whose `dueBy`
|
|
35
|
+
* has passed AND ORC hasn't replied, enqueue a `[DELIVER_REQUIRED]`
|
|
36
|
+
* system message to ORC via MessageQueueService. ORC's prompt
|
|
37
|
+
* treats `[DELIVER_REQUIRED]` exactly like `[CHAT:slack-…]` for
|
|
38
|
+
* the pre-yield gate.
|
|
39
|
+
*
|
|
40
|
+
* 4. Exponential backoff between reminders for the same thread, to
|
|
41
|
+
* avoid spamming ORC if it's genuinely stuck or rate-limited.
|
|
42
|
+
* First reminder at 3 min, then 10 min, then 30 min, then stop.
|
|
43
|
+
*
|
|
44
|
+
* Fire-and-forget on all writes — a fault in the enforcer MUST NOT
|
|
45
|
+
* block chat or slack flow.
|
|
46
|
+
*
|
|
47
|
+
* @module services/orc/orc-delivery-enforcer.service
|
|
48
|
+
*/
|
|
49
|
+
/**
|
|
50
|
+
* Identifier for a slack thread — channelId + threadTs (or empty if
|
|
51
|
+
* not a slack conversation, in which case we skip tracking entirely).
|
|
52
|
+
*/
|
|
53
|
+
export interface SlackThreadKey {
|
|
54
|
+
channelId: string;
|
|
55
|
+
threadTs: string;
|
|
56
|
+
}
|
|
57
|
+
export interface PendingDelivery {
|
|
58
|
+
key: SlackThreadKey;
|
|
59
|
+
/** The full conversation id, e.g. `slack-D0AC…-1779…`. Stored for the reminder text. */
|
|
60
|
+
conversationId: string;
|
|
61
|
+
/** Session name of the agent that posted [DONE]. */
|
|
62
|
+
agentSender: string;
|
|
63
|
+
/** First ~200 chars of the agent's [DONE] message — surfaces in the reminder. */
|
|
64
|
+
deliverableSummary: string;
|
|
65
|
+
/** When the agent posted (ms). */
|
|
66
|
+
doneAt: number;
|
|
67
|
+
/** Number of reminders fired so far (drives REMINDER_CADENCE_MS index). */
|
|
68
|
+
remindersFired: number;
|
|
69
|
+
/** Next eligible reminder time (ms). */
|
|
70
|
+
nextDueAt: number;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Function the enforcer uses to send a `[DELIVER_REQUIRED]` message to
|
|
74
|
+
* ORC. Injected at construction time so tests can stub it and so the
|
|
75
|
+
* service has no hard dep on MessageQueueService.
|
|
76
|
+
*/
|
|
77
|
+
export type DeliveryReminderSink = (params: {
|
|
78
|
+
conversationId: string;
|
|
79
|
+
text: string;
|
|
80
|
+
}) => void;
|
|
81
|
+
export interface OrcDeliveryEnforcerOptions {
|
|
82
|
+
/** Wired from index.ts to enqueue the reminder via MessageQueueService. */
|
|
83
|
+
reminderSink: DeliveryReminderSink;
|
|
84
|
+
/** Scan interval. Default 30 s. */
|
|
85
|
+
tickIntervalMs?: number;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Singleton scope — there's one ORC, one queue of pending deliveries.
|
|
89
|
+
*/
|
|
90
|
+
export declare class OrcDeliveryEnforcerService {
|
|
91
|
+
private static instance;
|
|
92
|
+
private readonly logger;
|
|
93
|
+
private readonly reminderSink;
|
|
94
|
+
private readonly tickIntervalMs;
|
|
95
|
+
private timer;
|
|
96
|
+
/** key = `${channelId}::${threadTs}` */
|
|
97
|
+
private readonly pending;
|
|
98
|
+
constructor(opts: OrcDeliveryEnforcerOptions);
|
|
99
|
+
static getInstance(): OrcDeliveryEnforcerService | null;
|
|
100
|
+
static setInstance(next: OrcDeliveryEnforcerService | null): void;
|
|
101
|
+
/** Start the periodic scan. Idempotent. */
|
|
102
|
+
start(): void;
|
|
103
|
+
stop(): void;
|
|
104
|
+
/**
|
|
105
|
+
* Call this from `chat.controller` when an agent posts a `[DONE]`-style
|
|
106
|
+
* status to a slack-prefixed conversation. Safe to call multiple times
|
|
107
|
+
* for the same thread — the most recent agent message wins (we
|
|
108
|
+
* surface only the latest deliverable summary in the reminder).
|
|
109
|
+
*/
|
|
110
|
+
markPendingDelivery(input: {
|
|
111
|
+
conversationId: string;
|
|
112
|
+
agentSender: string;
|
|
113
|
+
text: string;
|
|
114
|
+
}): void;
|
|
115
|
+
/**
|
|
116
|
+
* Call this from `slack.controller` after a successful `/api/slack/send`.
|
|
117
|
+
* Cleared regardless of which agent originated the underlying deliverable.
|
|
118
|
+
*/
|
|
119
|
+
markDelivered(input: {
|
|
120
|
+
channelId: string;
|
|
121
|
+
threadTs?: string | null;
|
|
122
|
+
}): void;
|
|
123
|
+
/** Test affordance — peek at the in-memory ledger. */
|
|
124
|
+
_peekPending(): PendingDelivery[];
|
|
125
|
+
/**
|
|
126
|
+
* Public for tests + the manual-trigger endpoint. Otherwise driven by
|
|
127
|
+
* the internal timer.
|
|
128
|
+
*/
|
|
129
|
+
tick(now?: number): {
|
|
130
|
+
firedFor: string[];
|
|
131
|
+
};
|
|
132
|
+
private formatReminder;
|
|
133
|
+
}
|
|
134
|
+
export declare function isAgentDeliveryMarker(text: string): boolean;
|
|
135
|
+
/**
|
|
136
|
+
* Parse a chat-v2 conversation id of the form `slack-<channelId>-<ts>`
|
|
137
|
+
* into { channelId, threadTs }. Returns null for non-slack ids. Mirrors
|
|
138
|
+
* the regex in `request-sla.subscriber` so the enforcer agrees with the
|
|
139
|
+
* SLA layer on what "the same thread" means.
|
|
140
|
+
*/
|
|
141
|
+
export declare function parseSlackConversationId(conversationId: string): SlackThreadKey | null;
|
|
142
|
+
//# sourceMappingURL=orc-delivery-enforcer.service.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"orc-delivery-enforcer.service.d.ts","sourceRoot":"","sources":["../../../../../../backend/src/services/orc/orc-delivery-enforcer.service.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+CG;AAgBH;;;GAGG;AACH,MAAM,WAAW,cAAc;IAC7B,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,eAAe;IAC9B,GAAG,EAAE,cAAc,CAAC;IACpB,wFAAwF;IACxF,cAAc,EAAE,MAAM,CAAC;IACvB,oDAAoD;IACpD,WAAW,EAAE,MAAM,CAAC;IACpB,iFAAiF;IACjF,kBAAkB,EAAE,MAAM,CAAC;IAC3B,kCAAkC;IAClC,MAAM,EAAE,MAAM,CAAC;IACf,2EAA2E;IAC3E,cAAc,EAAE,MAAM,CAAC;IACvB,wCAAwC;IACxC,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;;;GAIG;AACH,MAAM,MAAM,oBAAoB,GAAG,CAAC,MAAM,EAAE;IAC1C,cAAc,EAAE,MAAM,CAAC;IACvB,IAAI,EAAE,MAAM,CAAC;CACd,KAAK,IAAI,CAAC;AAEX,MAAM,WAAW,0BAA0B;IACzC,2EAA2E;IAC3E,YAAY,EAAE,oBAAoB,CAAC;IACnC,mCAAmC;IACnC,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED;;GAEG;AACH,qBAAa,0BAA0B;IACrC,OAAO,CAAC,MAAM,CAAC,QAAQ,CAA2C;IAClE,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAkB;IACzC,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAuB;IACpD,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAS;IACxC,OAAO,CAAC,KAAK,CAA+B;IAC5C,wCAAwC;IACxC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAsC;gBAElD,IAAI,EAAE,0BAA0B;IAM5C,MAAM,CAAC,WAAW,IAAI,0BAA0B,GAAG,IAAI;IAIvD,MAAM,CAAC,WAAW,CAAC,IAAI,EAAE,0BAA0B,GAAG,IAAI,GAAG,IAAI;IAKjE,2CAA2C;IAC3C,KAAK,IAAI,IAAI;IAUb,IAAI,IAAI,IAAI;IAQZ;;;;;OAKG;IACH,mBAAmB,CAAC,KAAK,EAAE;QACzB,cAAc,EAAE,MAAM,CAAC;QACvB,WAAW,EAAE,MAAM,CAAC;QACpB,IAAI,EAAE,MAAM,CAAC;KACd,GAAG,IAAI;IAyBR;;;OAGG;IACH,aAAa,CAAC,KAAK,EAAE;QAAE,SAAS,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,GAAG,IAAI;IAY3E,sDAAsD;IACtD,YAAY,IAAI,eAAe,EAAE;IAIjC;;;OAGG;IACH,IAAI,CAAC,GAAG,GAAE,MAAmB,GAAG;QAAE,QAAQ,EAAE,MAAM,EAAE,CAAA;KAAE;IA8CtD,OAAO,CAAC,cAAc;CAevB;AAMD,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAI3D;AAED;;;;;GAKG;AACH,wBAAgB,wBAAwB,CACtC,cAAc,EAAE,MAAM,GACrB,cAAc,GAAG,IAAI,CAyBvB"}
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OrcDeliveryEnforcerService
|
|
3
|
+
*
|
|
4
|
+
* **Why this exists** (2026-05-23 incident, the "affiliate research" case):
|
|
5
|
+
*
|
|
6
|
+
* Steve asked the team to research affiliate programs at 12:59 PM. ORC
|
|
7
|
+
* acknowledged at 1:00 PM with "Atlas + Sage/Kai 派出去了, ETA 30-40min".
|
|
8
|
+
* Sage finished Phase 1 at 1:15, Kai finished Phase 2 at 1:28 — but
|
|
9
|
+
* Steve never saw the result. ORC's terminal logs claimed the pipeline
|
|
10
|
+
* was "closed" and the result "already routed to Steve at Slack thread
|
|
11
|
+
* ...", but there were zero `/api/slack/send` calls all day. ORC's
|
|
12
|
+
* internal mental model believed delivery happened; the actual skill
|
|
13
|
+
* call didn't.
|
|
14
|
+
*
|
|
15
|
+
* Root cause: ORC's prompt has a HARD pre-yield gate ("any `[CHAT:slack-…]`
|
|
16
|
+
* MUST have a `reply-slack` before yielding"). But for a long-running
|
|
17
|
+
* delegation, the user-visible deliverable comes from the *agent* via
|
|
18
|
+
* `[DONE]`-style status reports — long AFTER ORC's initial ack. ORC
|
|
19
|
+
* reads the agent's `[DONE]` as internal team chatter, narrates "pipeline
|
|
20
|
+
* closed", and yields without delivering to Steve. The prompt rule
|
|
21
|
+
* doesn't catch this case because the prompt looks for `[CHAT:slack-…]`
|
|
22
|
+
* (user-originating) not `[DONE]` (agent-originating).
|
|
23
|
+
*
|
|
24
|
+
* **What this service does:**
|
|
25
|
+
*
|
|
26
|
+
* 1. When a worker agent posts `[DONE]` / `[COMPLETED]` to a
|
|
27
|
+
* slack-prefixed conversation (chat.controller's "Agent status
|
|
28
|
+
* routed to orchestrator" path), call {@link markPendingDelivery}.
|
|
29
|
+
* The service records a pending delivery with `dueBy = now + 3 min`.
|
|
30
|
+
*
|
|
31
|
+
* 2. When ORC successfully posts via `/api/slack/send` to the same
|
|
32
|
+
* thread, call {@link markDelivered}. Pending delivery cleared.
|
|
33
|
+
*
|
|
34
|
+
* 3. A timer scans every 30 s. For any pending delivery whose `dueBy`
|
|
35
|
+
* has passed AND ORC hasn't replied, enqueue a `[DELIVER_REQUIRED]`
|
|
36
|
+
* system message to ORC via MessageQueueService. ORC's prompt
|
|
37
|
+
* treats `[DELIVER_REQUIRED]` exactly like `[CHAT:slack-…]` for
|
|
38
|
+
* the pre-yield gate.
|
|
39
|
+
*
|
|
40
|
+
* 4. Exponential backoff between reminders for the same thread, to
|
|
41
|
+
* avoid spamming ORC if it's genuinely stuck or rate-limited.
|
|
42
|
+
* First reminder at 3 min, then 10 min, then 30 min, then stop.
|
|
43
|
+
*
|
|
44
|
+
* Fire-and-forget on all writes — a fault in the enforcer MUST NOT
|
|
45
|
+
* block chat or slack flow.
|
|
46
|
+
*
|
|
47
|
+
* @module services/orc/orc-delivery-enforcer.service
|
|
48
|
+
*/
|
|
49
|
+
import { LoggerService } from '../core/logger.service.js';
|
|
50
|
+
/** Reminder cadence (ms): first reminder at 3 min, then 10 min, then 30 min, then stop. */
|
|
51
|
+
const REMINDER_CADENCE_MS = [
|
|
52
|
+
3 * 60 * 1000,
|
|
53
|
+
10 * 60 * 1000,
|
|
54
|
+
30 * 60 * 1000,
|
|
55
|
+
];
|
|
56
|
+
const DEFAULT_TICK_INTERVAL_MS = 30 * 1000;
|
|
57
|
+
/** Markers in agent output that indicate user-facing delivery is ready. */
|
|
58
|
+
const DELIVERY_MARKERS = ['[DONE]', '[COMPLETED]', '[DELIVERED]'];
|
|
59
|
+
/**
|
|
60
|
+
* Singleton scope — there's one ORC, one queue of pending deliveries.
|
|
61
|
+
*/
|
|
62
|
+
export class OrcDeliveryEnforcerService {
|
|
63
|
+
static instance = null;
|
|
64
|
+
logger;
|
|
65
|
+
reminderSink;
|
|
66
|
+
tickIntervalMs;
|
|
67
|
+
timer = null;
|
|
68
|
+
/** key = `${channelId}::${threadTs}` */
|
|
69
|
+
pending = new Map();
|
|
70
|
+
constructor(opts) {
|
|
71
|
+
this.logger = LoggerService.getInstance().createComponentLogger('OrcDeliveryEnforcer');
|
|
72
|
+
this.reminderSink = opts.reminderSink;
|
|
73
|
+
this.tickIntervalMs = opts.tickIntervalMs ?? DEFAULT_TICK_INTERVAL_MS;
|
|
74
|
+
}
|
|
75
|
+
static getInstance() {
|
|
76
|
+
return this.instance;
|
|
77
|
+
}
|
|
78
|
+
static setInstance(next) {
|
|
79
|
+
if (this.instance && this.instance !== next)
|
|
80
|
+
this.instance.stop();
|
|
81
|
+
this.instance = next;
|
|
82
|
+
}
|
|
83
|
+
/** Start the periodic scan. Idempotent. */
|
|
84
|
+
start() {
|
|
85
|
+
if (this.timer)
|
|
86
|
+
return;
|
|
87
|
+
this.timer = setInterval(() => this.tick(), this.tickIntervalMs);
|
|
88
|
+
this.timer.unref?.();
|
|
89
|
+
this.logger.info('OrcDeliveryEnforcer started', {
|
|
90
|
+
tickIntervalMs: this.tickIntervalMs,
|
|
91
|
+
cadenceMinutes: REMINDER_CADENCE_MS.map((n) => Math.round(n / 60000)),
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
stop() {
|
|
95
|
+
if (this.timer) {
|
|
96
|
+
clearInterval(this.timer);
|
|
97
|
+
this.timer = null;
|
|
98
|
+
this.logger.info('OrcDeliveryEnforcer stopped');
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Call this from `chat.controller` when an agent posts a `[DONE]`-style
|
|
103
|
+
* status to a slack-prefixed conversation. Safe to call multiple times
|
|
104
|
+
* for the same thread — the most recent agent message wins (we
|
|
105
|
+
* surface only the latest deliverable summary in the reminder).
|
|
106
|
+
*/
|
|
107
|
+
markPendingDelivery(input) {
|
|
108
|
+
if (!isAgentDeliveryMarker(input.text))
|
|
109
|
+
return;
|
|
110
|
+
const key = parseSlackConversationId(input.conversationId);
|
|
111
|
+
if (!key)
|
|
112
|
+
return; // not a slack thread — ignore
|
|
113
|
+
const k = serializeKey(key);
|
|
114
|
+
const now = Date.now();
|
|
115
|
+
const summary = input.text.slice(0, 200);
|
|
116
|
+
this.pending.set(k, {
|
|
117
|
+
key,
|
|
118
|
+
conversationId: input.conversationId,
|
|
119
|
+
agentSender: input.agentSender,
|
|
120
|
+
deliverableSummary: summary,
|
|
121
|
+
doneAt: now,
|
|
122
|
+
remindersFired: 0,
|
|
123
|
+
nextDueAt: now + REMINDER_CADENCE_MS[0],
|
|
124
|
+
});
|
|
125
|
+
this.logger.info('OrcDeliveryEnforcer pending delivery recorded', {
|
|
126
|
+
conversationId: input.conversationId,
|
|
127
|
+
agentSender: input.agentSender,
|
|
128
|
+
summaryPreview: summary.slice(0, 80),
|
|
129
|
+
firstReminderInSec: REMINDER_CADENCE_MS[0] / 1000,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Call this from `slack.controller` after a successful `/api/slack/send`.
|
|
134
|
+
* Cleared regardless of which agent originated the underlying deliverable.
|
|
135
|
+
*/
|
|
136
|
+
markDelivered(input) {
|
|
137
|
+
if (!input.channelId || !input.threadTs)
|
|
138
|
+
return;
|
|
139
|
+
const k = serializeKey({ channelId: input.channelId, threadTs: input.threadTs });
|
|
140
|
+
const had = this.pending.delete(k);
|
|
141
|
+
if (had) {
|
|
142
|
+
this.logger.info('OrcDeliveryEnforcer delivery cleared by reply-slack', {
|
|
143
|
+
channelId: input.channelId,
|
|
144
|
+
threadTs: input.threadTs,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
/** Test affordance — peek at the in-memory ledger. */
|
|
149
|
+
_peekPending() {
|
|
150
|
+
return [...this.pending.values()];
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Public for tests + the manual-trigger endpoint. Otherwise driven by
|
|
154
|
+
* the internal timer.
|
|
155
|
+
*/
|
|
156
|
+
tick(now = Date.now()) {
|
|
157
|
+
const firedFor = [];
|
|
158
|
+
for (const [k, item] of this.pending) {
|
|
159
|
+
if (now < item.nextDueAt)
|
|
160
|
+
continue;
|
|
161
|
+
if (item.remindersFired >= REMINDER_CADENCE_MS.length) {
|
|
162
|
+
// Out of reminder budget — drop the entry so we stop spamming.
|
|
163
|
+
this.pending.delete(k);
|
|
164
|
+
this.logger.warn('OrcDeliveryEnforcer reminder budget exhausted — giving up', {
|
|
165
|
+
conversationId: item.conversationId,
|
|
166
|
+
remindersFired: item.remindersFired,
|
|
167
|
+
});
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
const text = this.formatReminder(item);
|
|
171
|
+
try {
|
|
172
|
+
this.reminderSink({
|
|
173
|
+
conversationId: item.conversationId,
|
|
174
|
+
text,
|
|
175
|
+
});
|
|
176
|
+
firedFor.push(item.conversationId);
|
|
177
|
+
this.logger.info('OrcDeliveryEnforcer reminder fired', {
|
|
178
|
+
conversationId: item.conversationId,
|
|
179
|
+
agentSender: item.agentSender,
|
|
180
|
+
reminderIndex: item.remindersFired,
|
|
181
|
+
minutesSinceDone: Math.round((now - item.doneAt) / 60000),
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
catch (err) {
|
|
185
|
+
this.logger.warn('OrcDeliveryEnforcer reminderSink threw (swallowed)', {
|
|
186
|
+
error: err.message,
|
|
187
|
+
conversationId: item.conversationId,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
item.remindersFired += 1;
|
|
191
|
+
const nextOffset = REMINDER_CADENCE_MS[item.remindersFired];
|
|
192
|
+
if (nextOffset === undefined) {
|
|
193
|
+
// Final reminder fired — drop the entry now so we don't keep
|
|
194
|
+
// re-evaluating it on every tick.
|
|
195
|
+
this.pending.delete(k);
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
item.nextDueAt = now + nextOffset;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return { firedFor };
|
|
202
|
+
}
|
|
203
|
+
formatReminder(item) {
|
|
204
|
+
const minutes = Math.round((Date.now() - item.doneAt) / 60000);
|
|
205
|
+
return [
|
|
206
|
+
`[DELIVER_REQUIRED] Steve is waiting on a deliverable in Slack thread ${item.conversationId}.`,
|
|
207
|
+
``,
|
|
208
|
+
`Agent \`${item.agentSender}\` posted [DONE] ${minutes} min ago with this content (preview):`,
|
|
209
|
+
`> ${item.deliverableSummary.slice(0, 200)}${item.deliverableSummary.length > 200 ? '…' : ''}`,
|
|
210
|
+
``,
|
|
211
|
+
`Action required: call reply-slack now with the deliverable. Treat this`,
|
|
212
|
+
`the same as a [CHAT:slack-...] message — do NOT yield the turn until`,
|
|
213
|
+
`the reply lands on the thread. If the agent's output isn't the final`,
|
|
214
|
+
`deliverable yet (e.g. it's "Phase 1 done" out of 2), send Steve a status`,
|
|
215
|
+
`update via reply-slack saying so + revised ETA, then continue waiting.`,
|
|
216
|
+
].join('\n');
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
// =============================================================================
|
|
220
|
+
// Helpers (exported for testing)
|
|
221
|
+
// =============================================================================
|
|
222
|
+
export function isAgentDeliveryMarker(text) {
|
|
223
|
+
if (!text)
|
|
224
|
+
return false;
|
|
225
|
+
const upper = text.toUpperCase();
|
|
226
|
+
return DELIVERY_MARKERS.some((m) => upper.includes(m));
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Parse a chat-v2 conversation id of the form `slack-<channelId>-<ts>`
|
|
230
|
+
* into { channelId, threadTs }. Returns null for non-slack ids. Mirrors
|
|
231
|
+
* the regex in `request-sla.subscriber` so the enforcer agrees with the
|
|
232
|
+
* SLA layer on what "the same thread" means.
|
|
233
|
+
*/
|
|
234
|
+
export function parseSlackConversationId(conversationId) {
|
|
235
|
+
if (!conversationId || !conversationId.startsWith('slack-'))
|
|
236
|
+
return null;
|
|
237
|
+
// Strip any per-message suffix (`-msg-<ts>`) so we key on thread root.
|
|
238
|
+
const stripped = conversationId.replace(/-msg-\d+\.\d+$/, '');
|
|
239
|
+
const rest = stripped.slice('slack-'.length);
|
|
240
|
+
const lastDash = rest.lastIndexOf('-');
|
|
241
|
+
if (lastDash < 1)
|
|
242
|
+
return null;
|
|
243
|
+
const channelId = rest.slice(0, lastDash);
|
|
244
|
+
let threadTs = rest.slice(lastDash + 1);
|
|
245
|
+
// Some producers serialize ts as `1779555555-588569` (dash) or
|
|
246
|
+
// `1779555555.588569` (dot). Normalize to dotted form to match
|
|
247
|
+
// Slack-API ts shape.
|
|
248
|
+
if (/^\d+$/.test(threadTs)) {
|
|
249
|
+
// No fractional segment in URL form — try one more split.
|
|
250
|
+
const beforeRest = rest.slice(0, lastDash);
|
|
251
|
+
const prevDash = beforeRest.lastIndexOf('-');
|
|
252
|
+
if (prevDash > 0) {
|
|
253
|
+
const candidate = `${beforeRest.slice(prevDash + 1)}.${threadTs}`;
|
|
254
|
+
if (/^\d+\.\d+$/.test(candidate)) {
|
|
255
|
+
return { channelId: beforeRest.slice(0, prevDash), threadTs: candidate };
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
// If threadTs already contains a dot (dotted form), keep as-is.
|
|
260
|
+
return { channelId, threadTs };
|
|
261
|
+
}
|
|
262
|
+
function serializeKey(k) {
|
|
263
|
+
return `${k.channelId}::${k.threadTs}`;
|
|
264
|
+
}
|
|
265
|
+
//# sourceMappingURL=orc-delivery-enforcer.service.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"orc-delivery-enforcer.service.js","sourceRoot":"","sources":["../../../../../../backend/src/services/orc/orc-delivery-enforcer.service.ts"],"names":[],"mappings":"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"}
|
|
@@ -6,7 +6,35 @@
|
|
|
6
6
|
*
|
|
7
7
|
* @module pty-session
|
|
8
8
|
*/
|
|
9
|
+
import * as pty from 'node-pty';
|
|
9
10
|
import type { ISession, SessionOptions } from '../session-backend.interface.js';
|
|
11
|
+
export declare function _setPtySpawnImplForTesting(impl: typeof pty.spawn): () => void;
|
|
12
|
+
/**
|
|
13
|
+
* Wraps `pty.spawn` with retry-on-transient + actionable error.
|
|
14
|
+
*
|
|
15
|
+
* On the 4th and final failure, throws `PtySpawnExhaustedError` with a
|
|
16
|
+
* human-readable message + machine-readable diagnostics. Callers should
|
|
17
|
+
* surface the .message to the user (e.g. ORC → Slack).
|
|
18
|
+
*/
|
|
19
|
+
declare class PtySpawnExhaustedError extends Error {
|
|
20
|
+
readonly diagnostics: {
|
|
21
|
+
attempts: number;
|
|
22
|
+
lastError: string;
|
|
23
|
+
userProcessCount: number | null;
|
|
24
|
+
maxProcPerUid: number | null;
|
|
25
|
+
command: string;
|
|
26
|
+
platform: string;
|
|
27
|
+
};
|
|
28
|
+
constructor(message: string, diagnostics: {
|
|
29
|
+
attempts: number;
|
|
30
|
+
lastError: string;
|
|
31
|
+
userProcessCount: number | null;
|
|
32
|
+
maxProcPerUid: number | null;
|
|
33
|
+
command: string;
|
|
34
|
+
platform: string;
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
export { PtySpawnExhaustedError };
|
|
10
38
|
/**
|
|
11
39
|
* PTY Session implementation using node-pty.
|
|
12
40
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"pty-session.d.ts","sourceRoot":"","sources":["../../../../../../../backend/src/services/session/pty/pty-session.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;
|
|
1
|
+
{"version":3,"file":"pty-session.d.ts","sourceRoot":"","sources":["../../../../../../../backend/src/services/session/pty/pty-session.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,GAAG,MAAM,UAAU,CAAC;AAEhC,OAAO,KAAK,EAAE,QAAQ,EAAE,cAAc,EAAE,MAAM,iCAAiC,CAAC;AAahF,wBAAgB,0BAA0B,CACzC,IAAI,EAAE,OAAO,GAAG,CAAC,KAAK,GACpB,MAAM,IAAI,CAMZ;AAsED;;;;;;GAMG;AACH,cAAM,sBAAuB,SAAQ,KAAK;aAGxB,WAAW,EAAE;QAC5B,QAAQ,EAAE,MAAM,CAAC;QACjB,SAAS,EAAE,MAAM,CAAC;QAClB,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAC;QAChC,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;QAC7B,OAAO,EAAE,MAAM,CAAC;QAChB,QAAQ,EAAE,MAAM,CAAC;KACjB;gBARD,OAAO,EAAE,MAAM,EACC,WAAW,EAAE;QAC5B,QAAQ,EAAE,MAAM,CAAC;QACjB,SAAS,EAAE,MAAM,CAAC;QAClB,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAC;QAChC,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;QAC7B,OAAO,EAAE,MAAM,CAAC;QAChB,QAAQ,EAAE,MAAM,CAAC;KACjB;CAKF;AA6DD,OAAO,EAAE,sBAAsB,EAAE,CAAC;AAElC;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,qBAAa,UAAW,YAAW,QAAQ;aA+CzB,IAAI,EAAE,MAAM;aACZ,GAAG,EAAE,MAAM;IA/C5B;;OAEG;IACH,OAAO,CAAC,UAAU,CAAW;IAE7B;;OAEG;IACH,OAAO,CAAC,aAAa,CAA0C;IAE/D;;OAEG;IACH,OAAO,CAAC,aAAa,CAA0C;IAE/D;;OAEG;IACH,OAAO,CAAC,MAAM,CAAS;IAEvB;;OAEG;IACH,OAAO,CAAC,MAAM,CAAkB;IAEhC;;;;;;;;;;;;;;;;;;;OAmBG;gBAEc,IAAI,EAAE,MAAM,EACZ,GAAG,EAAE,MAAM,EAC3B,OAAO,EAAE,cAAc;IAqExB;;OAEG;IACH,IAAI,GAAG,IAAI,MAAM,CAEhB;IAED;;;;;;;;;;;;;;OAcG;IACH,MAAM,CAAC,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,GAAG,MAAM,IAAI;IAepD;;;;;;;;;;;;;OAaG;IACH,MAAM,CAAC,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,GAAG,MAAM,IAAI;IAepD;;;;;;;;;;;OAWG;IACH,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI;IAOzB;;;;;;;;;;;OAWG;IACH,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,IAAI;IAqBxC;;;;;;;;;;;;;OAaG;IACH,IAAI,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI;IAa3B;;;;;;;;;;;;;OAaG;IACG,SAAS,IAAI,OAAO,CAAC,IAAI,CAAC;IAyChC;;;;OAIG;IACH,QAAQ,IAAI,OAAO;IAInB;;;;;;;;OAQG;IACH,mBAAmB,IAAI,OAAO;IAgB9B;;OAEG;IACH,OAAO,CAAC,kBAAkB;IAyC1B;;;;;OAKG;IACH,OAAO,CAAC,WAAW;CASnB"}
|
|
@@ -11,6 +11,152 @@ import { execSync } from 'child_process';
|
|
|
11
11
|
import { DEFAULT_TERMINAL_COLS, DEFAULT_TERMINAL_ROWS, } from '../session-backend.interface.js';
|
|
12
12
|
import { PTY_CONSTANTS } from '../../../constants.js';
|
|
13
13
|
import { LoggerService } from '../../core/logger.service.js';
|
|
14
|
+
/**
|
|
15
|
+
* Test affordance: lets `pty-session.test.ts` swap in a stub instead of
|
|
16
|
+
* calling the real `pty.spawn`. Production code path is identical.
|
|
17
|
+
*/
|
|
18
|
+
let ptySpawnImpl = pty.spawn;
|
|
19
|
+
export function _setPtySpawnImplForTesting(impl) {
|
|
20
|
+
const previous = ptySpawnImpl;
|
|
21
|
+
ptySpawnImpl = impl;
|
|
22
|
+
return () => {
|
|
23
|
+
ptySpawnImpl = previous;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Retry policy for transient `pty.spawn` failures. The native node-pty
|
|
28
|
+
* binding throws a generic `Error("posix_spawnp failed.")` with no errno
|
|
29
|
+
* detail — on macOS this is usually EAGAIN (process table briefly
|
|
30
|
+
* saturated by Chrome helpers / VS Code extensions / build spawns) and
|
|
31
|
+
* retrying after a short pause succeeds. We give up after the 4th
|
|
32
|
+
* attempt and throw a richer diagnostic.
|
|
33
|
+
*
|
|
34
|
+
* Sync sleep uses `Atomics.wait` on an ephemeral SharedArrayBuffer —
|
|
35
|
+
* blocks the thread without spawning a `sleep(1)` subprocess (which
|
|
36
|
+
* would compound the very problem we're working around).
|
|
37
|
+
*/
|
|
38
|
+
const PTY_SPAWN_RETRY_BACKOFFS_MS = [0, 150, 400, 1000];
|
|
39
|
+
const PTY_TRANSIENT_ERROR_MARKERS = [
|
|
40
|
+
'posix_spawnp',
|
|
41
|
+
'EAGAIN',
|
|
42
|
+
'ENOMEM',
|
|
43
|
+
'EMFILE',
|
|
44
|
+
'ENFILE',
|
|
45
|
+
];
|
|
46
|
+
function isTransientSpawnError(err) {
|
|
47
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
48
|
+
return PTY_TRANSIENT_ERROR_MARKERS.some((m) => msg.includes(m));
|
|
49
|
+
}
|
|
50
|
+
function syncSleepMs(ms) {
|
|
51
|
+
if (ms <= 0)
|
|
52
|
+
return;
|
|
53
|
+
const buf = new SharedArrayBuffer(4);
|
|
54
|
+
const view = new Int32Array(buf);
|
|
55
|
+
// Wait returns 'timed-out' after `ms` regardless of view[0] (which
|
|
56
|
+
// nobody writes to). This is the modern synchronous sleep pattern
|
|
57
|
+
// recommended for situations where a busy-wait would burn CPU.
|
|
58
|
+
Atomics.wait(view, 0, 0, ms);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Read current user process count cheaply. Used in the spawn-failure
|
|
62
|
+
* diagnostic to tell the user if they're near macOS `kern.maxprocperuid`.
|
|
63
|
+
*/
|
|
64
|
+
function readUserProcessCount() {
|
|
65
|
+
try {
|
|
66
|
+
const out = execSync('ps -u "$(id -un)" 2>/dev/null | wc -l', {
|
|
67
|
+
encoding: 'utf8',
|
|
68
|
+
timeout: 800,
|
|
69
|
+
});
|
|
70
|
+
const n = parseInt(out.trim(), 10);
|
|
71
|
+
return Number.isFinite(n) ? n : null;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/** Read kern.maxprocperuid. macOS-specific; returns null on non-Darwin. */
|
|
78
|
+
function readMaxProcPerUid() {
|
|
79
|
+
if (process.platform !== 'darwin')
|
|
80
|
+
return null;
|
|
81
|
+
try {
|
|
82
|
+
const out = execSync('sysctl -n kern.maxprocperuid', {
|
|
83
|
+
encoding: 'utf8',
|
|
84
|
+
timeout: 500,
|
|
85
|
+
});
|
|
86
|
+
const n = parseInt(out.trim(), 10);
|
|
87
|
+
return Number.isFinite(n) ? n : null;
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Wraps `pty.spawn` with retry-on-transient + actionable error.
|
|
95
|
+
*
|
|
96
|
+
* On the 4th and final failure, throws `PtySpawnExhaustedError` with a
|
|
97
|
+
* human-readable message + machine-readable diagnostics. Callers should
|
|
98
|
+
* surface the .message to the user (e.g. ORC → Slack).
|
|
99
|
+
*/
|
|
100
|
+
class PtySpawnExhaustedError extends Error {
|
|
101
|
+
diagnostics;
|
|
102
|
+
constructor(message, diagnostics) {
|
|
103
|
+
super(message);
|
|
104
|
+
this.diagnostics = diagnostics;
|
|
105
|
+
this.name = 'PtySpawnExhaustedError';
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
function spawnPtyWithRetry(command, args, options, logger) {
|
|
109
|
+
let lastError = null;
|
|
110
|
+
for (let attempt = 0; attempt < PTY_SPAWN_RETRY_BACKOFFS_MS.length; attempt++) {
|
|
111
|
+
const backoffMs = PTY_SPAWN_RETRY_BACKOFFS_MS[attempt];
|
|
112
|
+
if (backoffMs > 0) {
|
|
113
|
+
logger.warn('PTY spawn retry — sleeping then retrying', {
|
|
114
|
+
attempt,
|
|
115
|
+
backoffMs,
|
|
116
|
+
command,
|
|
117
|
+
});
|
|
118
|
+
syncSleepMs(backoffMs);
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
return ptySpawnImpl(command, args, options);
|
|
122
|
+
}
|
|
123
|
+
catch (err) {
|
|
124
|
+
lastError = err;
|
|
125
|
+
if (!isTransientSpawnError(err)) {
|
|
126
|
+
// Non-transient (e.g. ENOENT — command not found): fail fast.
|
|
127
|
+
throw err;
|
|
128
|
+
}
|
|
129
|
+
// Else loop — backoff already happened next iter.
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// Exhausted retries — build an actionable message.
|
|
133
|
+
const userProcessCount = readUserProcessCount();
|
|
134
|
+
const maxProcPerUid = readMaxProcPerUid();
|
|
135
|
+
const errMsg = lastError instanceof Error ? lastError.message : String(lastError);
|
|
136
|
+
let humanHint = '';
|
|
137
|
+
if (userProcessCount !== null &&
|
|
138
|
+
maxProcPerUid !== null &&
|
|
139
|
+
userProcessCount > maxProcPerUid * 0.75) {
|
|
140
|
+
humanHint = ` Your user process count is ${userProcessCount} / ${maxProcPerUid} (>75% of limit). Close some Chrome tabs / VS Code windows / other agent sessions and retry.`;
|
|
141
|
+
}
|
|
142
|
+
else if (process.platform === 'darwin') {
|
|
143
|
+
humanHint =
|
|
144
|
+
' Likely a transient kernel-level process-table spike (Chrome / VS Code / build tools spawning many helpers). Wait ~30 s and the next attempt should succeed; the OSS will auto-retry on the next user trigger.';
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
humanHint = ' Transient spawn failure — retry shortly.';
|
|
148
|
+
}
|
|
149
|
+
const message = `PTY spawn failed after ${PTY_SPAWN_RETRY_BACKOFFS_MS.length} attempts (${errMsg}).${humanHint}`;
|
|
150
|
+
throw new PtySpawnExhaustedError(message, {
|
|
151
|
+
attempts: PTY_SPAWN_RETRY_BACKOFFS_MS.length,
|
|
152
|
+
lastError: errMsg,
|
|
153
|
+
userProcessCount,
|
|
154
|
+
maxProcPerUid,
|
|
155
|
+
command,
|
|
156
|
+
platform: process.platform,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
export { PtySpawnExhaustedError };
|
|
14
160
|
/**
|
|
15
161
|
* PTY Session implementation using node-pty.
|
|
16
162
|
*
|
|
@@ -97,14 +243,18 @@ export class PtySession {
|
|
|
97
243
|
TERM: 'xterm-256color',
|
|
98
244
|
};
|
|
99
245
|
try {
|
|
100
|
-
// Spawn the PTY process
|
|
101
|
-
|
|
246
|
+
// Spawn the PTY process — wrapped in retry-with-backoff so that
|
|
247
|
+
// transient `posix_spawnp failed` errors (typical on macOS when
|
|
248
|
+
// Chrome / VS Code briefly saturate the process table) don't
|
|
249
|
+
// turn into user-visible spawn failures. See spawnPtyWithRetry
|
|
250
|
+
// for the policy + sync sleep pattern.
|
|
251
|
+
this.ptyProcess = spawnPtyWithRetry(options.command, options.args ?? [], {
|
|
102
252
|
name: 'xterm-256color',
|
|
103
253
|
cols: options.cols ?? DEFAULT_TERMINAL_COLS,
|
|
104
254
|
rows: options.rows ?? DEFAULT_TERMINAL_ROWS,
|
|
105
255
|
cwd: options.cwd,
|
|
106
256
|
env: sessionEnv,
|
|
107
|
-
});
|
|
257
|
+
}, this.logger);
|
|
108
258
|
this.logger.info('PTY process spawned successfully', {
|
|
109
259
|
name,
|
|
110
260
|
pid: this.ptyProcess.pid,
|
|
@@ -112,11 +262,19 @@ export class PtySession {
|
|
|
112
262
|
});
|
|
113
263
|
}
|
|
114
264
|
catch (spawnError) {
|
|
265
|
+
// `PtySpawnExhaustedError` carries the actionable hint already
|
|
266
|
+
// in its .message; other errors (e.g. ENOENT — command not
|
|
267
|
+
// found) propagate as-is. Either way we log richer context.
|
|
268
|
+
const errMsg = spawnError instanceof Error ? spawnError.message : String(spawnError);
|
|
269
|
+
const diagnostics = spawnError instanceof PtySpawnExhaustedError
|
|
270
|
+
? spawnError.diagnostics
|
|
271
|
+
: undefined;
|
|
115
272
|
this.logger.error('Failed to spawn PTY process', {
|
|
116
273
|
name,
|
|
117
274
|
command: options.command,
|
|
118
275
|
cwd: options.cwd,
|
|
119
|
-
error:
|
|
276
|
+
error: errMsg,
|
|
277
|
+
diagnostics,
|
|
120
278
|
stack: spawnError instanceof Error ? spawnError.stack : undefined,
|
|
121
279
|
});
|
|
122
280
|
throw spawnError;
|