factory-ai 1.4.0 → 1.5.1

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.
Files changed (61) hide show
  1. package/CHANGELOG.md +22 -4
  2. package/Dockerfile.worker +1 -1
  3. package/README.md +8 -2
  4. package/ROADMAP.md +0 -1
  5. package/bin/factory +118 -1
  6. package/bootstrap/agent-factory-worker.service +2 -2
  7. package/bootstrap/auto-update.sh +20 -12
  8. package/bootstrap/deploy-runtime.sh +5 -3
  9. package/bootstrap/factory-ai-container-firewall.service +14 -0
  10. package/bootstrap/factory-ai-snapshot.service +1 -0
  11. package/bootstrap/factory-ai-watchdog.service +21 -0
  12. package/bootstrap/factory-ai-watchdog.timer +11 -0
  13. package/bootstrap/setup.sh +20 -9
  14. package/cmd/factory-ui/main.go +455 -0
  15. package/go.mod +39 -0
  16. package/go.sum +82 -0
  17. package/package.json +7 -3
  18. package/scripts/test.js +7 -0
  19. package/src/acp-adapter.js +26 -0
  20. package/src/acp-cli.js +16 -0
  21. package/src/activity.js +96 -0
  22. package/src/agent-executor.js +39 -5
  23. package/src/agent-runner.js +30 -10
  24. package/src/approval-policy.js +12 -0
  25. package/src/azure-harness.js +47 -14
  26. package/src/bedrock-harness.js +45 -14
  27. package/src/config.js +9 -0
  28. package/src/container-runner.js +65 -12
  29. package/src/context-compaction.js +16 -0
  30. package/src/control-plane.js +77 -16
  31. package/src/control-service.js +6 -1
  32. package/src/dashboard.js +26 -6
  33. package/src/extension-cli.js +11 -0
  34. package/src/extension-manifest.js +76 -0
  35. package/src/hooks.js +62 -0
  36. package/src/instructions.js +45 -0
  37. package/src/mcp-tools.js +19 -3
  38. package/src/objective-status.js +16 -0
  39. package/src/operator-logs.js +24 -0
  40. package/src/operator.js +31 -9
  41. package/src/process.js +3 -3
  42. package/src/release-gate.js +1 -0
  43. package/src/release-service.js +4 -1
  44. package/src/repo-map.js +128 -0
  45. package/src/reporter.js +11 -0
  46. package/src/retriever.js +18 -18
  47. package/src/routing.js +11 -1
  48. package/src/scanner-suite.js +4 -3
  49. package/src/snapshot.js +16 -0
  50. package/src/task-entry.js +28 -6
  51. package/src/task-graph.js +0 -5
  52. package/src/telegram-service.js +7 -1
  53. package/src/telegram.js +5 -1
  54. package/src/telemetry.js +173 -0
  55. package/src/tui.js +27 -8
  56. package/src/validation.js +41 -9
  57. package/src/watchdog.js +62 -0
  58. package/src/worker.js +32 -3
  59. package/src/workspace-tools.js +13 -3
  60. package/src/workspace.js +22 -2
  61. package/templates/AGENTS.md +15 -0
@@ -0,0 +1,173 @@
1
+ import { randomBytes } from "node:crypto";
2
+
3
+ export const TELEMETRY_SCHEMA_VERSION = "factory.telemetry.v1";
4
+
5
+ const operationNames = Object.freeze({
6
+ model: "gen_ai.chat",
7
+ tool: "gen_ai.execute_tool",
8
+ queue: "factory.queue",
9
+ checkpoint: "factory.checkpoint",
10
+ scanner: "factory.scanner",
11
+ watchdog: "factory.watchdog",
12
+ release: "factory.release",
13
+ });
14
+
15
+ const aliases = Object.freeze({
16
+ objectiveId: "factory.objective.id",
17
+ taskId: "factory.task.id",
18
+ role: "factory.agent.role",
19
+ modelRoute: "gen_ai.request.model",
20
+ attempt: "factory.attempt",
21
+ toolCallId: "gen_ai.tool.call.id",
22
+ messageId: "messaging.message.id",
23
+ inputTokens: "gen_ai.usage.input_tokens",
24
+ outputTokens: "gen_ai.usage.output_tokens",
25
+ cacheReadInputTokens: "gen_ai.usage.cache_read.input_tokens",
26
+ durationMs: "factory.duration_ms",
27
+ retryCount: "factory.retry.count",
28
+ cacheHit: "factory.cache.hit",
29
+ statusClass: "factory.status.class",
30
+ errorCode: "error.type",
31
+ });
32
+
33
+ const identifier = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/;
34
+ const modelRoute = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}\/[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/;
35
+ const providerName = /^[a-z][a-z0-9._-]{0,63}$/;
36
+ const errorCode = /^[a-z][a-z0-9_.-]{0,63}$/;
37
+ const statusClasses = new Set(["ok", "error", "cancelled", "timeout", "retry"]);
38
+ const genAiOperations = Object.freeze({ model: "chat", tool: "execute_tool" });
39
+
40
+ function nonNegativeInteger(value) {
41
+ return Number.isSafeInteger(value) && value >= 0;
42
+ }
43
+
44
+ function nonNegativeNumber(value) {
45
+ return typeof value === "number" && Number.isFinite(value) && value >= 0;
46
+ }
47
+
48
+ const validators = Object.freeze({
49
+ "factory.objective.id": (value) => typeof value === "string" && identifier.test(value),
50
+ "factory.task.id": (value) => typeof value === "string" && identifier.test(value),
51
+ "factory.agent.role": (value) => typeof value === "string" && identifier.test(value),
52
+ "gen_ai.request.model": (value) => typeof value === "string" && modelRoute.test(value) && !value.includes("://"),
53
+ "factory.attempt": nonNegativeInteger,
54
+ "gen_ai.tool.call.id": (value) => typeof value === "string" && identifier.test(value),
55
+ "messaging.message.id": (value) => typeof value === "string" && identifier.test(value),
56
+ "gen_ai.provider.name": (value) => typeof value === "string" && providerName.test(value),
57
+ "gen_ai.operation.name": (value) => value === "chat" || value === "execute_tool",
58
+ "gen_ai.usage.input_tokens": nonNegativeInteger,
59
+ "gen_ai.usage.output_tokens": nonNegativeInteger,
60
+ "gen_ai.usage.cache_read.input_tokens": nonNegativeInteger,
61
+ "factory.duration_ms": nonNegativeNumber,
62
+ "factory.retry.count": nonNegativeInteger,
63
+ "factory.cache.hit": (value) => typeof value === "boolean",
64
+ "factory.status.class": (value) => statusClasses.has(value),
65
+ "error.type": (value) => typeof value === "string" && errorCode.test(value),
66
+ });
67
+
68
+ export function safeAttributes(input = {}) {
69
+ const result = {};
70
+ if (!input || typeof input !== "object" || Array.isArray(input)) return result;
71
+ for (const [inputName, value] of Object.entries(input)) {
72
+ const name = aliases[inputName] ?? inputName;
73
+ if (validators[name]?.(value)) result[name] = value;
74
+ }
75
+ return result;
76
+ }
77
+
78
+ function operationName(kind) {
79
+ const name = operationNames[kind];
80
+ if (!name) throw new Error(`Unknown telemetry operation: ${kind}`);
81
+ return name;
82
+ }
83
+
84
+ function validHexId(value, length) {
85
+ return typeof value === "string" && new RegExp(`^[a-f0-9]{${length}}$`).test(value);
86
+ }
87
+
88
+ export function createTelemetry(options = {}) {
89
+ const {
90
+ exporter,
91
+ fallback,
92
+ now = Date.now,
93
+ createTraceId = () => randomBytes(16).toString("hex"),
94
+ createSpanId = () => randomBytes(8).toString("hex"),
95
+ } = options;
96
+
97
+ function ids(context) {
98
+ const traceId = validHexId(context?.traceId, 32) ? context.traceId : createTraceId();
99
+ const spanId = createSpanId();
100
+ if (!validHexId(traceId, 32) || !validHexId(spanId, 16)) throw new Error("Telemetry ID generators must return lowercase hexadecimal IDs");
101
+ return {
102
+ traceId,
103
+ spanId,
104
+ ...(validHexId(context?.parentSpanId, 16) ? { parentSpanId: context.parentSpanId } : {}),
105
+ };
106
+ }
107
+
108
+ async function deliver(record) {
109
+ if (typeof exporter === "function") {
110
+ try {
111
+ await exporter(record);
112
+ return record;
113
+ } catch {}
114
+ }
115
+ if (typeof fallback === "function") {
116
+ try { await fallback(record); } catch {}
117
+ }
118
+ return record;
119
+ }
120
+
121
+ function baseRecord(recordType, kind, context) {
122
+ const timestamp = new Date(now()).toISOString();
123
+ return {
124
+ schemaVersion: TELEMETRY_SCHEMA_VERSION,
125
+ recordType,
126
+ name: operationName(kind),
127
+ timestamp,
128
+ ...ids(context),
129
+ };
130
+ }
131
+
132
+ function startSpan(kind, context = {}, attributes = {}) {
133
+ const startedAt = now();
134
+ const base = baseRecord("span", kind, context);
135
+ const initialAttributes = safeAttributes({
136
+ ...context,
137
+ ...attributes,
138
+ ...(genAiOperations[kind] ? { "gen_ai.operation.name": genAiOperations[kind] } : {}),
139
+ });
140
+ let completion;
141
+ return {
142
+ traceId: base.traceId,
143
+ spanId: base.spanId,
144
+ end(endAttributes = {}) {
145
+ if (!completion) {
146
+ const measured = endAttributes.durationMs === undefined
147
+ ? { durationMs: Math.max(0, now() - startedAt), ...endAttributes }
148
+ : endAttributes;
149
+ const record = {
150
+ ...base,
151
+ attributes: { ...initialAttributes, ...safeAttributes(measured) },
152
+ };
153
+ completion = deliver(record);
154
+ }
155
+ return completion;
156
+ },
157
+ };
158
+ }
159
+
160
+ function emitEvent(kind, context = {}, attributes = {}) {
161
+ const record = {
162
+ ...baseRecord("event", kind, context),
163
+ attributes: safeAttributes({
164
+ ...context,
165
+ ...attributes,
166
+ ...(genAiOperations[kind] ? { "gen_ai.operation.name": genAiOperations[kind] } : {}),
167
+ }),
168
+ };
169
+ return deliver(record);
170
+ }
171
+
172
+ return Object.freeze({ startSpan, emitEvent });
173
+ }
package/src/tui.js CHANGED
@@ -13,7 +13,7 @@ const colors = { bg: "#0d0f12", panel: "#15191f", border: "#303743", text: "#d9e
13
13
  blessed.box({ parent: screen, top: 0, left: 0, width: "100%", height: 3, tags: true, style: { bg: colors.panel, fg: colors.text }, content: ` {bold}{#78dba9-fg}${factoryName.toUpperCase()}{/} {/bold} ${factoryPurpose}` });
14
14
  const menu = blessed.list({ parent: screen, top: 3, left: 0, width: 23, bottom: 2, border: { type: "line" }, label: " Navigate ", keys: true, mouse: true, vi: true, items: ["Overview", "Objectives", "Agents", "Secrets", "Capabilities", "Logs", "Settings"], style: { bg: colors.panel, fg: colors.text, border: { fg: colors.border }, selected: { bg: colors.accent, fg: "#07130d", bold: true }, item: { fg: colors.text } } });
15
15
  const main = blessed.box({ parent: screen, top: 3, left: 23, right: 0, bottom: 2, border: { type: "line" }, label: " Overview ", tags: true, scrollable: true, alwaysScroll: true, keys: true, mouse: true, vi: true, scrollbar: { ch: "▐", style: { fg: colors.accent } }, padding: { left: 2, right: 2 }, style: { bg: colors.bg, fg: colors.text, border: { fg: colors.border } } });
16
- const footer = blessed.box({ parent: screen, bottom: 0, left: 0, width: "100%", height: 2, tags: true, style: { bg: colors.panel, fg: colors.muted }, content: " {bold}n{/} new objective {bold}r{/} refresh {bold}a{/} add secret {bold}p/u{/} pause/resume {bold}q{/} quit" });
16
+ const footer = blessed.box({ parent: screen, bottom: 0, left: 0, width: "100%", height: 2, tags: true, style: { bg: colors.panel, fg: colors.muted }, content: " {bold}n{/} new {bold}r{/} refresh {bold}a{/} secret {bold}y/x{/} approve/deny {bold}p/u{/} pause/resume {bold}q{/} quit" });
17
17
 
18
18
  let dashboard;
19
19
  let capabilities;
@@ -22,6 +22,11 @@ let logs;
22
22
  let section = "Overview";
23
23
  let refreshing = false;
24
24
 
25
+ function safe(value) {
26
+ const text = String(value ?? "").replaceAll(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, "");
27
+ return blessed.escape ? blessed.escape(text) : text.replaceAll("{", "\\{").replaceAll("}", "\\}");
28
+ }
29
+
25
30
  function status(message, color = colors.muted) {
26
31
  footer.setContent(` {${color}-fg}${message}{/} {bold}n{/} new {bold}r{/} refresh {bold}q{/} quit`);
27
32
  screen.render();
@@ -39,20 +44,20 @@ function renderOverview() {
39
44
  const inputTokens = modelUsage.reduce((sum, item) => sum + item.inputTokens, 0);
40
45
  const cachedTokens = modelUsage.reduce((sum, item) => sum + item.cachedInputTokens, 0);
41
46
  const outputTokens = modelUsage.reduce((sum, item) => sum + item.outputTokens, 0);
42
- const recent = (dashboard?.objectives ?? []).slice(-8).reverse().map((objective) => ` ${badge(objective.status.padEnd(9))} {bold}${objective.objective}{/}\n ${objective.repository ?? ""}`).join("\n\n");
47
+ const recent = (dashboard?.objectives ?? []).slice(-8).reverse().map((objective) => ` ${badge(objective.status.padEnd(9))} {bold}${safe(objective.objective)}{/}\n ${safe(objective.repository ?? "")}`).join("\n\n");
43
48
  main.setContent(`{bold}System{/}\n\n Queue {#78dba9-fg}${dashboard?.queue?.active ?? 0}{/}\n Dead letter ${dashboard?.queue?.deadLetter ?? 0}\n Azure MTD {#efc46b-fg}${cost}{/}\n Objectives ${counts}\n Tokens ${inputTokens} in · ${cachedTokens} cached · ${outputTokens} out\n\n{bold}Recent objectives{/}\n\n${recent || " No objectives yet."}`);
44
49
  }
45
50
 
46
51
  function renderObjectives() {
47
52
  main.setContent((dashboard?.objectives ?? []).slice().reverse().map((objective) => {
48
- const tasks = objective.tasks.map((task) => ` ${badge(task.state.padEnd(9))} ${task.role.padEnd(9)} {#7f8b99-fg}${task.model}{/}\n ${task.title}`).join("\n");
49
- return `${badge(objective.status)} {bold}${objective.objective}{/}\n ${objective.id}\n${tasks}${objective.pullRequest ? `\n PR ${objective.pullRequest}` : ""}${objective.blocker ? `\n {#ef7d7d-fg}${objective.blocker}{/}` : ""}`;
53
+ const tasks = objective.tasks.map((task) => ` ${badge((task.stale ? "stale" : task.state).padEnd(9))} ${task.role.padEnd(9)} {#7f8b99-fg}${safe(task.model)}{/}\n ${safe(task.title)}${task.activity ? ` · ${safe(task.activity.type)}${task.activity.tool ? ` ${safe(task.activity.tool)}` : ""}` : ""}`).join("\n");
54
+ return `${badge(objective.status)} {bold}${safe(objective.objective)}{/}\n ${safe(objective.id)}\n${tasks}${objective.pullRequest ? `\n PR ${safe(objective.pullRequest)}` : ""}${objective.blocker ? `\n {#ef7d7d-fg}${safe(objective.blocker)}{/}` : ""}`;
50
55
  }).join("\n\n────────────────────────────────────────────────────────\n\n") || "No objectives." );
51
56
  }
52
57
 
53
58
  function renderAgents() {
54
59
  const agents = (dashboard?.objectives ?? []).flatMap((objective) => objective.tasks.map((task) => ({ objective: objective.objective, ...task }))).filter((task) => !["succeeded"].includes(task.state));
55
- main.setContent(agents.map((task) => `${badge(task.state.padEnd(9))} {bold}${task.role}{/} ${task.model}\n ${task.title}\n {#7f8b99-fg}${task.objective}{/}`).join("\n\n") || "No active agents.");
60
+ main.setContent(agents.map((task) => `${badge((task.stale ? "stale" : task.state).padEnd(9))} {bold}${safe(task.role)}{/} ${safe(task.model)}\n ${safe(task.title)}\n ${task.activity ? `${safe(task.activity.phase ?? task.activity.type)}${task.activity.tool ? ` · ${safe(task.activity.tool)}` : ""} · ${Math.round(task.activityAgeSeconds ?? 0)}s ago${task.retries ? ` · ${task.retries} retries` : ""}` : "No activity event yet"}${task.lastError ? `\n {#ef7d7d-fg}${safe(task.lastError)}{/}` : ""}\n {#7f8b99-fg}${safe(task.objective)}{/}`).join("\n\n") || "No active agents.");
56
61
  }
57
62
 
58
63
  function renderSecrets() {
@@ -77,7 +82,7 @@ function render() {
77
82
  else if (section === "Agents") renderAgents();
78
83
  else if (section === "Secrets") renderSecrets();
79
84
  else if (section === "Capabilities") renderCapabilities();
80
- else if (section === "Logs") main.setContent(logs ?? "Loading logs...");
85
+ else if (section === "Logs") main.setContent(safe(logs ?? "Loading logs..."));
81
86
  else renderSettings();
82
87
  main.setScrollPerc(0);
83
88
  screen.render();
@@ -90,7 +95,7 @@ async function refresh() {
90
95
  try {
91
96
  dashboard = await operator.dashboard();
92
97
  if (section === "Capabilities" && !capabilities) capabilities = await operator.capabilities();
93
- if (section === "Secrets") secrets = await operator.listSecrets();
98
+ if (section === "Secrets") secrets = dashboard?.secrets ?? await operator.listSecrets();
94
99
  if (section === "Logs") logs = await operator.logs();
95
100
  render();
96
101
  status(`Updated ${new Date().toLocaleTimeString()}`);
@@ -123,7 +128,19 @@ async function addSecret() {
123
128
  const value = await ask("Secret value", { secret: true });
124
129
  if (!value) return;
125
130
  status("Storing secret...");
126
- try { await operator.setSecret(name, value); secrets = await operator.listSecrets(); renderSecrets(); status(`Stored ${name}`, colors.accent); } catch (error) { status(error.message, colors.danger); }
131
+ try {
132
+ await operator.setSecret(name, value);
133
+ secrets = [...(secrets ?? []).filter((item) => item.name !== name), { name, updated: new Date().toISOString() }].sort((left, right) => left.name.localeCompare(right.name));
134
+ renderSecrets();
135
+ status(`Stored ${name}`, colors.accent);
136
+ } catch (error) { status(error.message, colors.danger); }
137
+ }
138
+
139
+ async function decideApproval(decision) {
140
+ const objectiveId = await ask("Objective ID"); if (!objectiveId) return;
141
+ const approvalId = await ask("Approval ID"); if (!approvalId) return;
142
+ const reason = await ask(`${decision === "approved" ? "Approval" : "Denial"} reason`); if (!reason) return;
143
+ try { await operator.approval({ objectiveId, approvalId, decision, reason }); status(`Approval ${decision}`, decision === "approved" ? colors.accent : colors.danger); await refresh(); } catch (error) { status(error.message, colors.danger); }
127
144
  }
128
145
 
129
146
  menu.on("select", async (_, index) => { section = menu.getItem(index).getText(); render(); await refresh(); menu.focus(); });
@@ -131,6 +148,8 @@ screen.key(["q", "C-c"], () => process.exit(0));
131
148
  screen.key("r", refresh);
132
149
  screen.key("n", submitObjective);
133
150
  screen.key("a", addSecret);
151
+ screen.key("y", () => decideApproval("approved"));
152
+ screen.key("x", () => decideApproval("denied"));
134
153
  screen.key("p", async () => { status("Pausing workers..."); try { await operator.control("pause"); status("Workers paused", colors.warn); } catch (error) { status(error.message, colors.danger); } });
135
154
  screen.key("u", async () => { status("Resuming workers..."); try { await operator.control("resume"); status("Workers active", colors.accent); } catch (error) { status(error.message, colors.danger); } });
136
155
  screen.key("tab", () => menu.focus());
package/src/validation.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { ROLES } from "./routing.js";
3
+ import { approvalPolicies } from "./approval-policy.js";
3
4
 
4
5
  const identifier = z.string().min(1).max(64).regex(/^[A-Za-z0-9][A-Za-z0-9_-]*$/);
5
6
  const repository = z.string().url().max(2048).refine((value) => {
@@ -48,11 +49,11 @@ const taskResultSchema = z.object({
48
49
  }).optional(),
49
50
  }).strict();
50
51
 
51
- export const workMessageSchema = z.object({
52
- type: z.literal("task"),
53
- objectiveId: identifier,
54
- task: taskSchema,
55
- }).strict();
52
+ const scannerEvidenceSchema = z.array(z.object({
53
+ scanner: z.string().min(1).max(100),
54
+ status: z.enum(["passed", "findings", "error"]),
55
+ output: z.string().max(6000),
56
+ }).strict()).max(20);
56
57
 
57
58
  const resultMessageSchema = taskResultSchema.extend({
58
59
  type: z.literal("result"),
@@ -61,6 +62,33 @@ const resultMessageSchema = taskResultSchema.extend({
61
62
  status: z.literal("succeeded"),
62
63
  commit: z.string().regex(/^[0-9a-f]{40,64}$/),
63
64
  branch: z.string().min(1).max(255).regex(/^[A-Za-z0-9][A-Za-z0-9._/-]*$/),
65
+ scannerEvidence: scannerEvidenceSchema.optional(),
66
+ }).strict();
67
+
68
+ const approvalRequestMessageSchema = z.object({
69
+ type: z.literal("approval_request"),
70
+ objectiveId: identifier,
71
+ approvalId: identifier,
72
+ policy: z.enum(approvalPolicies),
73
+ reason: z.string().trim().min(1).max(1000),
74
+ actor: z.string().trim().min(1).max(200),
75
+ requestedAt: z.string().datetime(),
76
+ expiresAt: z.string().datetime(),
77
+ checkpoint: z.string().min(1).max(500).optional(),
78
+ }).strict().refine((value) => Date.parse(value.expiresAt) > Date.parse(value.requestedAt), {
79
+ message: "Approval expiration must follow its request time",
80
+ path: ["expiresAt"],
81
+ });
82
+
83
+ const approvalDecisionMessageSchema = z.object({
84
+ type: z.literal("approval_decision"),
85
+ objectiveId: identifier,
86
+ approvalId: identifier,
87
+ decision: z.enum(["approved", "denied", "expired"]),
88
+ actor: z.string().trim().min(1).max(200),
89
+ reason: z.string().trim().min(1).max(1000),
90
+ decidedAt: z.string().datetime(),
91
+ messageId: identifier,
64
92
  }).strict();
65
93
 
66
94
  export function parseObjective(value) {
@@ -71,10 +99,6 @@ export function parsePlan(value) {
71
99
  return planSchema.parse(value);
72
100
  }
73
101
 
74
- export function parseWorkMessage(value) {
75
- return workMessageSchema.parse(value);
76
- }
77
-
78
102
  export function parseTaskResult(value) {
79
103
  return taskResultSchema.parse(value);
80
104
  }
@@ -82,3 +106,11 @@ export function parseTaskResult(value) {
82
106
  export function parseResultMessage(value) {
83
107
  return resultMessageSchema.parse(value);
84
108
  }
109
+
110
+ export function parseApprovalRequestMessage(value) {
111
+ return approvalRequestMessageSchema.parse(value);
112
+ }
113
+
114
+ export function parseApprovalDecisionMessage(value) {
115
+ return approvalDecisionMessageSchema.parse(value);
116
+ }
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env node
2
+ import { pathToFileURL } from "node:url";
3
+ import { ServiceBusClient } from "@azure/service-bus";
4
+ import { DefaultAzureCredential } from "@azure/identity";
5
+ import { loadConfig } from "./config.js";
6
+ import { loadLocalState } from "./dashboard.js";
7
+ import { sendMessage } from "./bus.js";
8
+ import { run } from "./process.js";
9
+ import { ActivityStore } from "./activity.js";
10
+ import { objectiveIsTerminal } from "./objective-status.js";
11
+
12
+ const TERMINAL = new Set(["complete", "failed", "blocked", "cancelled", "denied", "expired"]);
13
+
14
+ export function findStaleAgents(states, now = new Date(), staleSeconds = 900) {
15
+ const stale = [];
16
+ for (const state of states) {
17
+ if (TERMINAL.has(state.status)) continue;
18
+ const tasks = state.tasks?.length ? state.tasks : state.status === "planning" ? [{ id: "planner0", role: "planner" }] : [];
19
+ for (const task of tasks) {
20
+ if (state.results?.[task.id]?.status !== "queued" && task.id !== "planner0") continue;
21
+ const occurredAt = state.activity?.[task.id]?.occurredAt ?? state.results?.[task.id]?.queuedAt ?? (task.id === "planner0" ? state.createdAt : undefined);
22
+ if (!occurredAt || now.getTime() - new Date(occurredAt).getTime() <= staleSeconds * 1000) continue;
23
+ stale.push({ objectiveId: state.objective.id, taskId: task.id, role: task.role, occurredAt });
24
+ }
25
+ }
26
+ return stale;
27
+ }
28
+
29
+ async function main() {
30
+ const config = loadConfig();
31
+ const { states } = await loadLocalState(config.stateDir);
32
+ const stale = findStaleAgents(states, new Date(), Number(process.env.FACTORY_WATCHDOG_STALE_SECONDS ?? 900));
33
+ if (stale.length === 0) return;
34
+ const client = new ServiceBusClient(config.serviceBusFqdn, new DefaultAzureCredential());
35
+ const sender = client.createSender(config.controlQueue);
36
+ const activityStore = new ActivityStore(config.stateDir);
37
+ try {
38
+ for (const item of stale) {
39
+ await activityStore.withTaskLock(item.objectiveId, item.taskId, async () => {
40
+ const latest = await activityStore.latestTask(item.objectiveId, item.taskId);
41
+ if (await objectiveIsTerminal(config.stateDir, item.objectiveId)) return;
42
+ if (latest?.occurredAt && latest.occurredAt !== item.occurredAt) return;
43
+ await sendMessage(sender, {
44
+ type: "failure_result",
45
+ objectiveId: item.objectiveId,
46
+ taskId: item.taskId,
47
+ error: `Watchdog stopped ${item.role} after no heartbeat since ${item.occurredAt}`,
48
+ }, `watchdog:${item.objectiveId}:${item.taskId}:${item.occurredAt}`, item.objectiveId);
49
+ const container = `factory-ai-${item.objectiveId}-${item.taskId}`.toLowerCase().replaceAll(/[^a-z0-9_.-]/g, "-").slice(0, 63);
50
+ await run("docker", ["rm", "--force", container], { allowExitCodes: [0, 1], timeoutMs: 30_000 });
51
+ });
52
+ }
53
+ } finally {
54
+ await sender.close();
55
+ await client.close();
56
+ }
57
+ }
58
+
59
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) main().catch((error) => {
60
+ process.stderr.write(`${error.message}\n`);
61
+ process.exitCode = 1;
62
+ });
package/src/worker.js CHANGED
@@ -10,19 +10,43 @@ import { sendMessage } from "./bus.js";
10
10
  import { ContainerAgentRunner } from "./container-runner.js";
11
11
  import { ScannerSuite } from "./scanner-suite.js";
12
12
  import { LocalRetriever } from "./retriever.js";
13
+ import { ActivityStore } from "./activity.js";
14
+ import { objectiveIsTerminal } from "./objective-status.js";
15
+ import { evaluateApprovalPolicy } from "./approval-policy.js";
13
16
 
14
17
  process.title = "factory-ai-worker";
15
18
  const config = loadConfig();
16
19
  Object.assign(process.env, await loadRuntimeSecrets(config));
17
20
  await run("gh", ["auth", "setup-git"], { timeoutMs: 60_000 });
18
21
  const bus = createBus(config, config.agentQueue, config.controlQueue);
19
- const sendControl = (message) => sendMessage(bus.sender, message, `${message.objectiveId}:${message.type}:${message.taskId ?? "plan"}:v1`, message.objectiveId);
22
+ const sendControl = (message) => sendMessage(bus.sender, message, `${message.objectiveId}:${message.type}:${message.approvalId ?? message.taskId ?? "plan"}:v1`, message.objectiveId);
23
+ const scannerSuite = new ScannerSuite();
24
+ async function requestApproval(input, context) {
25
+ if (context.message.approvalGranted) return { skipped: true };
26
+ const now = new Date();
27
+ const approvalId = `approval-${context.message.task.id}`.slice(0, 64);
28
+ await sendControl({ type: "approval_request", objectiveId: context.message.objectiveId, approvalId, policy: input.policy, reason: input.reason, actor: "factory-policy", requestedAt: now.toISOString(), expiresAt: input.expiresAt ?? new Date(now.getTime() + 86_400_000).toISOString(), checkpoint: context.message.task.id });
29
+ return { approvalId };
30
+ }
20
31
  const executor = new AgentExecutor({
21
32
  workspaces: new WorkspaceManager(config.workspaceDir, config.timeoutMs),
22
- agentRunner: new ContainerAgentRunner({ image: config.workerImage, memoryDir: config.memoryDir, timeoutMs: config.timeoutMs }),
23
- scannerSuite: new ScannerSuite(),
33
+ agentRunner: new ContainerAgentRunner({ image: config.workerImage, memoryDir: config.memoryDir, timeoutMs: config.timeoutMs, activityStore: new ActivityStore(config.stateDir) }),
34
+ scannerSuite,
24
35
  retriever: new LocalRetriever({ stateDir: config.stateDir }),
36
+ repoMapMaxCharacters: config.repoMapMaxCharacters,
25
37
  sendControl,
38
+ hooks: config.hooks,
39
+ hookHandlers: {
40
+ scanner: (input, context) => scannerSuite.scan(context.directory, { names: input.scanners }),
41
+ policy_check: async (input, context) => {
42
+ const evaluated = evaluateApprovalPolicy(Object.fromEntries(input.policies.map((policy) => [policy, new RegExp(policy.replaceAll("_", ".*"), "i").test(`${context.message.task.title} ${context.message.task.instructions}`)])));
43
+ if (!evaluated.required) return evaluated;
44
+ return { ...evaluated, ...await requestApproval({ policy: evaluated.policies[0], reason: `Policy requires approval: ${evaluated.policies.join(", ")}` }, context) };
45
+ },
46
+ notification: async (input, context) => log("info", "hook_notification", { objectiveId: context.message.objectiveId, taskId: context.message.task.id, message: input.message }),
47
+ snapshot: async (input) => ({ label: input.label ?? "checkpoint" }),
48
+ approval_request: requestApproval,
49
+ },
26
50
  });
27
51
 
28
52
  let shuttingDown = false;
@@ -30,6 +54,11 @@ const subscription = bus.receiver.subscribe({
30
54
  processMessage: async (message) => {
31
55
  const body = message.body;
32
56
  try {
57
+ if (await objectiveIsTerminal(config.stateDir, body?.objectiveId)) {
58
+ log("info", "terminal_objective_message_discarded", { objectiveId: body.objectiveId, taskId: body.task?.id });
59
+ await bus.receiver.completeMessage(message);
60
+ return;
61
+ }
33
62
  await executor.process(body);
34
63
  await bus.receiver.completeMessage(message);
35
64
  } catch (error) {
@@ -5,6 +5,7 @@ import { run } from "./process.js";
5
5
 
6
6
  const ALLOWED_COMMANDS = new Set(["git", "ls", "node", "npm", "npx", "pwd", "rg"]);
7
7
  const DENIED_GIT_OPERATIONS = new Set(["credential", "push", "remote"]);
8
+ const READ_ONLY_GIT_OPERATIONS = new Set(["diff", "grep", "log", "ls-files", "rev-parse", "show", "status"]);
8
9
 
9
10
  function lexicalPath(root, requested) {
10
11
  const target = path.resolve(root, requested);
@@ -43,10 +44,11 @@ async function walk(directory, root, output, limit) {
43
44
  }
44
45
  }
45
46
 
46
- export function createWorkspaceTools(rootInput, { execute = run } = {}) {
47
+ export function createWorkspaceTools(rootInput, { execute = run, mutable = true, allowTests = false } = {}) {
47
48
  const root = realpathSync(path.resolve(rootInput));
48
- return {
49
+ const tools = {
49
50
  read_file: {
51
+ parallelSafe: true,
50
52
  description: "Read a bounded UTF-8 line range inside the assigned workspace. Use offsetLine/limitLines instead of rereading large files.",
51
53
  parameters: {
52
54
  type: "object",
@@ -69,6 +71,7 @@ export function createWorkspaceTools(rootInput, { execute = run } = {}) {
69
71
  },
70
72
  },
71
73
  write_file: {
74
+ parallelSafe: false,
72
75
  description: "Atomically replace a UTF-8 file inside the assigned workspace.",
73
76
  parameters: {
74
77
  type: "object",
@@ -86,6 +89,7 @@ export function createWorkspaceTools(rootInput, { execute = run } = {}) {
86
89
  },
87
90
  },
88
91
  list_files: {
92
+ parallelSafe: true,
89
93
  description: "List files recursively inside the assigned workspace.",
90
94
  parameters: {
91
95
  type: "object",
@@ -101,6 +105,7 @@ export function createWorkspaceTools(rootInput, { execute = run } = {}) {
101
105
  },
102
106
  },
103
107
  run_command: {
108
+ parallelSafe: false,
104
109
  description: "Run an allowlisted noninteractive development command in the assigned workspace.",
105
110
  parameters: {
106
111
  type: "object",
@@ -113,7 +118,10 @@ export function createWorkspaceTools(rootInput, { execute = run } = {}) {
113
118
  },
114
119
  execute: async ({ command, args }) => {
115
120
  if (!ALLOWED_COMMANDS.has(command)) throw new Error(`Command not allowed: ${command}`);
116
- if (command === "git" && DENIED_GIT_OPERATIONS.has(args[0])) throw new Error(`Git operation not allowed: ${args[0]}`);
121
+ const testCommand = allowTests && ((command === "npm" && ["test", "run"].includes(args[0])) || (command === "node" && args[0] === "--test") || (command === "npx" && args[0] === "--no-install"));
122
+ if (!mutable && !testCommand && !["git", "ls", "pwd", "rg"].includes(command)) throw new Error(`Command not allowed for read-only role: ${command}`);
123
+ if (command === "git" && (args.some((item) => DENIED_GIT_OPERATIONS.has(item)) || args.some((item) => ["-c", "--config-env", "--exec-path"].includes(item)))) throw new Error("Git operation not allowed");
124
+ if (!mutable && command === "git" && (args[0]?.startsWith("-") || !READ_ONLY_GIT_OPERATIONS.has(args[0]))) throw new Error(`Git operation not allowed for read-only role: ${args[0]}`);
117
125
  if (command === "npx" && args[0] !== "--no-install") throw new Error("npx requires --no-install");
118
126
  const result = await execute(command, args, {
119
127
  cwd: root,
@@ -127,4 +135,6 @@ export function createWorkspaceTools(rootInput, { execute = run } = {}) {
127
135
  },
128
136
  },
129
137
  };
138
+ if (!mutable) delete tools.write_file;
139
+ return tools;
130
140
  }
package/src/workspace.js CHANGED
@@ -45,10 +45,18 @@ export class WorkspaceManager {
45
45
 
46
46
  async prepareTask(objective, task, dependencyCommits = []) {
47
47
  const directory = this.taskDirectory(objective.id, task.id);
48
- if (await exists(directory)) return directory;
48
+ const branch = `factory-ai/${objective.id}/${task.id}`;
49
+ if (await exists(directory)) {
50
+ const current = (await run("git", ["-C", directory, "branch", "--show-current"])).stdout.trim();
51
+ if (current !== branch) throw new Error(`Existing task workspace is on unexpected branch: ${current}`);
52
+ for (const commit of dependencyCommits) {
53
+ const ancestor = await run("git", ["-C", directory, "merge-base", "--is-ancestor", commit, "HEAD"], { allowExitCodes: [0, 1] });
54
+ if (ancestor.code !== 0) throw new Error(`Existing task workspace is missing dependency commit: ${commit}`);
55
+ }
56
+ return directory;
57
+ }
49
58
  await mkdir(path.dirname(directory), { recursive: true, mode: 0o750 });
50
59
  const base = dependencyCommits[0] ?? `origin/${objective.baseBranch}`;
51
- const branch = `factory-ai/${objective.id}/${task.id}`;
52
60
  await run("git", ["clone", objective.repository, directory], { timeoutMs: this.timeoutMs });
53
61
  await run("git", ["-C", directory, "config", "user.name", "Factory AI"]);
54
62
  await run("git", ["-C", directory, "config", "user.email", "factory-ai@localhost"]);
@@ -60,6 +68,13 @@ export class WorkspaceManager {
60
68
  return directory;
61
69
  }
62
70
 
71
+ async recoveryContext(directory) {
72
+ const status = (await run("git", ["-C", directory, "status", "--short"], { maxOutputBytes: 50_000 })).stdout.trim();
73
+ if (!status) return "";
74
+ const summary = (await run("git", ["-C", directory, "diff", "--stat"], { maxOutputBytes: 50_000 })).stdout.trim();
75
+ return `RECOVERED DURABLE WORKTREE CHECKPOINT\nA previous attempt changed these files. Inspect and continue rather than repeating completed work.\n${status}${summary ? `\n\n${summary}` : ""}`;
76
+ }
77
+
63
78
  async checkpoint(directory, objective, task) {
64
79
  await run("git", ["-C", directory, "add", "-A"]);
65
80
  const diff = await run("git", ["-C", directory, "diff", "--cached", "--quiet"], { allowExitCodes: [0, 1] });
@@ -75,4 +90,9 @@ export class WorkspaceManager {
75
90
  return { commit, branch };
76
91
  }
77
92
 
93
+ async reference(directory, objective, task) {
94
+ const { stdout } = await run("git", ["-C", directory, "rev-parse", "HEAD"]);
95
+ return { commit: stdout.trim(), branch: `factory-ai/${objective.id}/${task.id}` };
96
+ }
97
+
78
98
  }
@@ -0,0 +1,15 @@
1
+ # Project Agent Instructions
2
+
3
+ ## Working Agreement
4
+
5
+ - Make the smallest correct change that satisfies the objective.
6
+ - Read existing code and tests before editing. Preserve established architecture and style.
7
+ - Never read, print, persist, or commit credentials or local environment files.
8
+ - Keep changes inside this repository. Do not push, deploy, or mutate external systems.
9
+ - Add or update focused tests for behavior changes.
10
+ - Run the project's documented format, typecheck, lint, test, and build gates before claiming completion.
11
+ - Report commands run, results, remaining risks, and any assumptions.
12
+
13
+ ## Project Commands
14
+
15
+ Record project-specific commands and constraints in `.agent-factory/commands.md` and `.agent-factory/project.md`.